home.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. import '../../state/app_state.dart';
  2. import 'package:pb_authenticator_state/state.dart';
  3. import 'package:flutter/material.dart';
  4. import '../../helper/url.dart' show launchURL;
  5. import '/l10n/app_localizations.dart';
  6. import 'package:provider/provider.dart';
  7. import '../../config/routes.dart';
  8. import '../../l10n/constants.dart';
  9. import './list_item.dart' show HomeListItem;
  10. enum SortOption { nameAsc, nameDesc }
  11. /// Android version of home page.
  12. class AndroidHomePage extends StatefulWidget {
  13. const AndroidHomePage({super.key});
  14. @override
  15. State<AndroidHomePage> createState() => _AndroidHomePageState();
  16. }
  17. class _AndroidHomePageState extends State<AndroidHomePage> {
  18. String _searchQuery = '';
  19. SortOption _currentSort = SortOption.nameAsc;
  20. final TextEditingController _searchController = TextEditingController();
  21. @override
  22. void dispose() {
  23. _searchController.dispose();
  24. super.dispose();
  25. }
  26. List<BaseItemType> _filterAndSortItems(List<BaseItemType> items) {
  27. // Filter by search query
  28. List<BaseItemType> filteredItems = items;
  29. if (_searchQuery.isNotEmpty) {
  30. filteredItems = items.where((item) {
  31. final nameMatch = item.totp.accountName.toLowerCase().contains(_searchQuery.toLowerCase());
  32. final issuerMatch = item.totp.issuer.toLowerCase().contains(_searchQuery.toLowerCase());
  33. return nameMatch || issuerMatch;
  34. }).toList();
  35. }
  36. // Sort items
  37. filteredItems.sort((a, b) {
  38. String aName = a.totp.accountName.isNotEmpty
  39. ? a.totp.accountName
  40. : (a.totp.issuer.isNotEmpty ? a.totp.issuer : 'Unknown');
  41. String bName = b.totp.accountName.isNotEmpty
  42. ? b.totp.accountName
  43. : (b.totp.issuer.isNotEmpty ? b.totp.issuer : 'Unknown');
  44. if (_currentSort == SortOption.nameAsc) {
  45. return aName.toLowerCase().compareTo(bName.toLowerCase());
  46. } else {
  47. return bName.toLowerCase().compareTo(aName.toLowerCase());
  48. }
  49. });
  50. return filteredItems;
  51. }
  52. /// Builds search and sort controls.
  53. Widget _buildSearchAndSort() {
  54. return Container(
  55. padding: const EdgeInsets.all(16.0),
  56. child: Column(
  57. children: [
  58. // Search field
  59. TextField(
  60. controller: _searchController,
  61. decoration: InputDecoration(
  62. hintText: AppLocalizations.of(context)!.searchHint,
  63. prefixIcon: const Icon(Icons.search),
  64. suffixIcon: _searchQuery.isNotEmpty
  65. ? IconButton(
  66. icon: const Icon(Icons.clear),
  67. onPressed: () {
  68. setState(() {
  69. _searchQuery = '';
  70. _searchController.clear();
  71. });
  72. },
  73. )
  74. : null,
  75. border: OutlineInputBorder(
  76. borderRadius: BorderRadius.circular(12),
  77. ),
  78. contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
  79. ),
  80. onChanged: (value) {
  81. setState(() {
  82. _searchQuery = value;
  83. });
  84. },
  85. ),
  86. const SizedBox(height: 12),
  87. // Sort options
  88. Row(
  89. children: [
  90. Text(
  91. AppLocalizations.of(context)!.sortBy,
  92. style: Theme.of(context).textTheme.titleSmall,
  93. ),
  94. const SizedBox(width: 16),
  95. Expanded(
  96. child: Row(
  97. children: [
  98. Expanded(
  99. child: RadioListTile<SortOption>(
  100. title: Row(
  101. mainAxisSize: MainAxisSize.min,
  102. children: [
  103. const Icon(Icons.arrow_upward, size: 16),
  104. const SizedBox(width: 4),
  105. Text(AppLocalizations.of(context)!.sortAscending),
  106. ],
  107. ),
  108. value: SortOption.nameAsc,
  109. groupValue: _currentSort,
  110. onChanged: (SortOption? value) {
  111. if (value != null) {
  112. setState(() {
  113. _currentSort = value;
  114. });
  115. }
  116. },
  117. contentPadding: EdgeInsets.zero,
  118. dense: true,
  119. ),
  120. ),
  121. Expanded(
  122. child: RadioListTile<SortOption>(
  123. title: Row(
  124. mainAxisSize: MainAxisSize.min,
  125. children: [
  126. const Icon(Icons.arrow_downward, size: 16),
  127. const SizedBox(width: 4),
  128. Text(AppLocalizations.of(context)!.sortDescending),
  129. ],
  130. ),
  131. value: SortOption.nameDesc,
  132. groupValue: _currentSort,
  133. onChanged: (SortOption? value) {
  134. if (value != null) {
  135. setState(() {
  136. _currentSort = value;
  137. });
  138. }
  139. },
  140. contentPadding: EdgeInsets.zero,
  141. dense: true,
  142. ),
  143. ),
  144. ],
  145. ),
  146. ),
  147. ],
  148. ),
  149. ],
  150. ),
  151. );
  152. }
  153. /// Builds list of items.
  154. Widget _buildList() {
  155. return Consumer<AppState>(
  156. builder: (context, state, child) {
  157. if (state.items == null) {
  158. // Items loading
  159. return Center(
  160. child: Padding(
  161. padding: const EdgeInsets.all(10),
  162. child: Text(AppLocalizations.of(context)!.loading)));
  163. } else if (state.items!.isEmpty) {
  164. // No Items
  165. return Center(
  166. child: Padding(
  167. padding: const EdgeInsets.all(10),
  168. child: Text(AppLocalizations.of(context)!.noAccounts,
  169. textAlign: TextAlign.center,
  170. style: const TextStyle(height: 1.2))));
  171. }
  172. final filteredAndSortedItems = _filterAndSortItems(state.items!);
  173. if (filteredAndSortedItems.isEmpty && _searchQuery.isNotEmpty) {
  174. // No search results
  175. return Center(
  176. child: Padding(
  177. padding: const EdgeInsets.all(10),
  178. child: Text(AppLocalizations.of(context)!.noSearchResults(_searchQuery),
  179. textAlign: TextAlign.center,
  180. style: const TextStyle(height: 1.2))));
  181. }
  182. return Container(
  183. margin: const EdgeInsets.all(10),
  184. child: ListView.builder(
  185. itemCount: filteredAndSortedItems.length,
  186. itemBuilder: (BuildContext context, int index) {
  187. var item = filteredAndSortedItems[index];
  188. return HomeListItem(item, key: Key(item.id));
  189. },
  190. ),
  191. );
  192. },
  193. );
  194. }
  195. /// Shows add modal
  196. void showAddModal(BuildContext parentContext) {
  197. // Show bottom sheet
  198. showModalBottomSheet<void>(
  199. context: parentContext,
  200. builder: (BuildContext context) {
  201. return Container(
  202. padding: const EdgeInsets.all(8.0),
  203. child: ListView(
  204. shrinkWrap: true,
  205. children: <Widget>[
  206. // Scan QR
  207. ListTile(
  208. title: Text(AppLocalizations.of(context)!.addScanQR),
  209. leading: const Icon(Icons.camera_alt),
  210. onTap: () async {
  211. Navigator.pop(context);
  212. var result =
  213. await Navigator.pushNamed(context, AppRoutes.addScan);
  214. if (result == null) {
  215. return;
  216. }
  217. // TODO: Transition to new type
  218. var item = result as LegacyAuthenticatorItem;
  219. await Provider.of<AppState>(parentContext, listen: false)
  220. .addItem(item.totp);
  221. },
  222. ),
  223. // Add account manually
  224. ListTile(
  225. title: Text(AppLocalizations.of(context)!.addManualInput),
  226. leading: const Icon(Icons.keyboard_return),
  227. onTap: () {
  228. Navigator.pop(context);
  229. Navigator.pushNamed(context, AppRoutes.add);
  230. },
  231. ),
  232. ],
  233. ),
  234. );
  235. },
  236. );
  237. }
  238. @override
  239. Widget build(BuildContext context) {
  240. return Scaffold(
  241. drawer: Drawer(
  242. child: ListView(
  243. padding: EdgeInsets.zero,
  244. children: <Widget>[
  245. DrawerHeader(
  246. decoration: BoxDecoration(
  247. color: Theme.of(context).primaryColor,
  248. ),
  249. child: Row(
  250. crossAxisAlignment: CrossAxisAlignment.start,
  251. children: [
  252. Text(
  253. AppLocalizations.of(context)!.appName,
  254. style: TextStyle(
  255. color: Colors.white,
  256. fontSize:
  257. Theme.of(context).textTheme.titleLarge!.fontSize),
  258. ),
  259. ],
  260. ),
  261. ),
  262. // Transfer codes
  263. ListTile(
  264. title: Text(AppLocalizations.of(context)!.transferCodesTitle ?? 'Transfer codes'),
  265. leading: const Icon(Icons.sync_alt),
  266. dense: true,
  267. onTap: () {
  268. Navigator.pop(context);
  269. Navigator.pushNamed(context, AppRoutes.transferCodes);
  270. },
  271. ),
  272. // How it works
  273. ListTile(
  274. title: Text(AppLocalizations.of(context)!.howItWorksTitle ?? 'How it works'),
  275. leading: const Icon(Icons.info_outline),
  276. dense: true,
  277. onTap: () {
  278. Navigator.pop(context);
  279. Navigator.pushNamed(context, AppRoutes.howItWorks);
  280. },
  281. ),
  282. // Settings
  283. ListTile(
  284. title: Text(AppLocalizations.of(context)!.settingsTitle),
  285. leading: const Icon(Icons.settings),
  286. dense: true,
  287. onTap: () {
  288. Navigator.pop(context);
  289. Navigator.pushNamed(context, AppRoutes.settings);
  290. }
  291. ),
  292. const Divider(height: 10),
  293. // Source code (in-app browser link)
  294. ListTile(
  295. title: Text(AppLocalizations.of(context)!.source),
  296. leading: const Icon(Icons.code),
  297. dense: true,
  298. onTap: () {
  299. Navigator.pop(context);
  300. launchURL(Constants.repoUrl);
  301. }),
  302. // Help
  303. ListTile(
  304. title: Text(AppLocalizations.of(context)!.helpTitle ?? 'Help'),
  305. leading: const Icon(Icons.help_outline),
  306. dense: true,
  307. onTap: () {
  308. Navigator.pop(context);
  309. Navigator.pushNamed(context, AppRoutes.help);
  310. },
  311. ),
  312. ],
  313. ),
  314. ),
  315. appBar: AppBar(
  316. title: Text(AppLocalizations.of(context)!.appName),
  317. actions: <Widget>[
  318. // Edit
  319. IconButton(
  320. icon: const Icon(Icons.edit),
  321. tooltip: AppLocalizations.of(context)!.edit,
  322. onPressed: () {
  323. Navigator.pushNamed(context, AppRoutes.edit);
  324. },
  325. ),
  326. // Add
  327. IconButton(
  328. icon: const Icon(Icons.add),
  329. tooltip: AppLocalizations.of(context)!.add,
  330. onPressed: () {
  331. showAddModal(context);
  332. },
  333. )
  334. ],
  335. ),
  336. body: Column(
  337. children: [
  338. _buildSearchAndSort(),
  339. Expanded(child: _buildList()),
  340. ],
  341. ),
  342. );
  343. }
  344. }