home.dart 13 KB

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