list_item.dart 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter/services.dart' show Clipboard, ClipboardData, rootBundle;
  3. import '../../state/app_state.dart';
  4. import '/l10n/app_localizations.dart';
  5. import '../shared/list_item_base.dart' show TotpListItemBase;
  6. import 'package:flutter_svg/flutter_svg.dart';
  7. import 'dart:convert';
  8. import 'dart:io';
  9. import 'package:path/path.dart' as p;
  10. import 'package:provider/provider.dart';
  11. import 'dart:async';
  12. // Top-level cache variables (no 'static' keyword needed)
  13. Map<String, dynamic>? _iconPackCache;
  14. List<String>? _allSvgIconsCache;
  15. Future<void>? _iconPackLoadFuture;
  16. class IconSelectorDialog extends StatefulWidget {
  17. final List<String> allSvgIcons;
  18. final Map<String, dynamic>? iconPack;
  19. const IconSelectorDialog({required this.allSvgIcons, required this.iconPack, super.key});
  20. @override
  21. State<IconSelectorDialog> createState() => _IconSelectorDialogState();
  22. }
  23. class _IconSelectorDialogState extends State<IconSelectorDialog> {
  24. static const int batchSize = 50;
  25. String search = '';
  26. List<String> filteredIcons = [];
  27. int loadedCount = batchSize;
  28. Timer? _debounce;
  29. final Map<String, Widget> _svgCache = {};
  30. final ScrollController _scrollController = ScrollController();
  31. @override
  32. void initState() {
  33. super.initState();
  34. filteredIcons = widget.allSvgIcons;
  35. _scrollController.addListener(_onScroll);
  36. }
  37. @override
  38. void dispose() {
  39. _debounce?.cancel();
  40. _scrollController.dispose();
  41. super.dispose();
  42. }
  43. void _onScroll() {
  44. if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
  45. if (loadedCount < filteredIcons.length) {
  46. setState(() {
  47. loadedCount = (loadedCount + batchSize).clamp(0, filteredIcons.length);
  48. });
  49. }
  50. }
  51. }
  52. void _onSearchChanged(String val) {
  53. if (_debounce?.isActive ?? false) _debounce!.cancel();
  54. _debounce = Timer(const Duration(milliseconds: 300), () {
  55. setState(() {
  56. search = val;
  57. filteredIcons = widget.allSvgIcons.where((iconPath) {
  58. final filename = iconPath.split('/').last.toLowerCase();
  59. if (widget.iconPack != null && widget.iconPack!['icons'] is List) {
  60. final iconMeta = (widget.iconPack!['icons'] as List).firstWhere(
  61. (icon) => icon is Map && icon['filename'] == filename,
  62. orElse: () => null,
  63. );
  64. final issuers = iconMeta is Map && iconMeta['issuer'] is List
  65. ? (iconMeta['issuer'] as List).join(' ').toLowerCase()
  66. : '';
  67. return filename.contains(val.toLowerCase()) || issuers.contains(val.toLowerCase());
  68. }
  69. return filename.contains(val.toLowerCase());
  70. }).toList();
  71. loadedCount = batchSize;
  72. });
  73. });
  74. }
  75. Widget _buildSvg(String iconPath) {
  76. if (_svgCache.containsKey(iconPath)) {
  77. return _svgCache[iconPath]!;
  78. }
  79. final assetPath = iconPath.replaceAll('\\', '/');
  80. final widgetSvg = SvgPicture.asset(
  81. assetPath,
  82. height: 32,
  83. width: 32,
  84. placeholderBuilder: (context) => Icon(Icons.shield_outlined, size: 32),
  85. );
  86. _svgCache[iconPath] = widgetSvg;
  87. return widgetSvg;
  88. }
  89. @override
  90. Widget build(BuildContext context) {
  91. // If filteredIcons is small, load all at once
  92. if (filteredIcons.length <= batchSize && loadedCount != filteredIcons.length) {
  93. loadedCount = filteredIcons.length;
  94. }
  95. final iconsToShow = filteredIcons.take(loadedCount).toList();
  96. return AlertDialog(
  97. title: Text('Choose Icon'),
  98. content: SizedBox(
  99. width: 400,
  100. height: 440,
  101. child: Column(
  102. children: [
  103. TextField(
  104. decoration: InputDecoration(
  105. labelText: 'Search by issuer or name',
  106. prefixIcon: Icon(Icons.search),
  107. ),
  108. onChanged: _onSearchChanged,
  109. ),
  110. const SizedBox(height: 10),
  111. Expanded(
  112. child: GridView.builder(
  113. controller: _scrollController,
  114. gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
  115. crossAxisCount: 5,
  116. mainAxisSpacing: 8,
  117. crossAxisSpacing: 8,
  118. ),
  119. itemCount: iconsToShow.length,
  120. itemBuilder: (context, index) {
  121. final iconPath = iconsToShow[index];
  122. return GestureDetector(
  123. onTap: () {
  124. Navigator.of(context).pop(iconPath);
  125. },
  126. child: _buildSvg(iconPath),
  127. );
  128. },
  129. ),
  130. ),
  131. if (loadedCount < filteredIcons.length)
  132. Padding(
  133. padding: const EdgeInsets.all(8.0),
  134. child: CircularProgressIndicator(strokeWidth: 2),
  135. ),
  136. ],
  137. ),
  138. ),
  139. actions: [
  140. TextButton(
  141. onPressed: () => Navigator.of(context).pop(),
  142. child: Text('Cancel'),
  143. ),
  144. ],
  145. );
  146. }
  147. }
  148. /// Home page list item (Android).
  149. class HomeListItem extends TotpListItemBase {
  150. const HomeListItem(super.item, {required Key super.key});
  151. @override
  152. State<StatefulWidget> createState() => _TOTPListItemState();
  153. }
  154. class _TOTPListItemState extends State<HomeListItem>
  155. with SingleTickerProviderStateMixin {
  156. // Dimension of progress indicator
  157. static const double _progressIndicatorDimension = 25;
  158. // Animation for indicator/code
  159. late AnimationController _controller;
  160. late Animation<double> _animation;
  161. // Code that is being displayed
  162. String _code = "";
  163. // Path to the selected icon (SVG asset)
  164. String? _iconPath;
  165. Map<String, dynamic>? _iconPack;
  166. List<String> _allSvgIcons = [];
  167. @override
  168. void dispose() {
  169. _controller.dispose();
  170. super.dispose();
  171. }
  172. @override
  173. void initState() {
  174. super.initState();
  175. final showIcons = Provider.of<AppState>(context, listen: false).showIcons;
  176. if (showIcons) {
  177. _loadIconPackAndIcons();
  178. }
  179. setState(() {
  180. _code = widget.code;
  181. });
  182. // Define animation
  183. // Adapted from progress_indicator_demo.dart from flutter examples
  184. _controller = AnimationController(
  185. duration: Duration(seconds: widget.item.totp.period),
  186. lowerBound: 0.0,
  187. upperBound: 1.0,
  188. vsync: this,
  189. animationBehavior: AnimationBehavior.preserve,
  190. );
  191. _controller.forward(from: widget.indicatorValue);
  192. _animation = Tween(begin: 0.0, end: 1.0).animate(_controller)
  193. ..addStatusListener(
  194. (AnimationStatus status) {
  195. if (status == AnimationStatus.completed) {
  196. // Set code
  197. setState(() {
  198. _code = widget.code;
  199. });
  200. // Reset animation
  201. _controller.forward(from: widget.indicatorValue);
  202. }
  203. },
  204. );
  205. }
  206. @override
  207. void didUpdateWidget(covariant HomeListItem oldWidget) {
  208. super.didUpdateWidget(oldWidget);
  209. final showIcons = Provider.of<AppState>(context, listen: false).showIcons;
  210. if (showIcons && _allSvgIcons.isEmpty) {
  211. print('didUpdateWidget: showIcons is true and _allSvgIcons is empty, loading icons...');
  212. _loadIconPackAndIcons();
  213. }
  214. }
  215. Future<void> _loadIconPackAndIcons() async {
  216. final showIcons = Provider.of<AppState>(context, listen: false).showIcons;
  217. print('_loadIconPackAndIcons called. showIcons: $showIcons');
  218. if (!showIcons) return;
  219. bool usedCache = false;
  220. if (_iconPackCache != null && _allSvgIconsCache != null) {
  221. setState(() {
  222. _iconPack = _iconPackCache;
  223. _allSvgIcons = _allSvgIconsCache!;
  224. });
  225. print('Using cached icon pack and icon list.');
  226. usedCache = true;
  227. } else {
  228. // Only one load at a time
  229. _iconPackLoadFuture ??= () async {
  230. try {
  231. final packJsonStr = await rootBundle.loadString('graphics/aegis-icons-outline/pack.json');
  232. final packJson = jsonDecode(packJsonStr);
  233. _iconPackCache = packJson;
  234. if (packJson['icons'] is List) {
  235. final icons = (packJson['icons'] as List)
  236. .map((icon) => icon['filename'] as String?)
  237. .where((filename) => filename != null)
  238. .map((filename) => 'graphics/aegis-icons-outline/$filename')
  239. .toList();
  240. _allSvgIconsCache = icons;
  241. print('Loaded ${icons.length} icons. First: ${icons.take(5).toList()}');
  242. }
  243. } catch (e) {
  244. print('Failed to load icon pack: $e');
  245. }
  246. }();
  247. await _iconPackLoadFuture;
  248. setState(() {
  249. _iconPack = _iconPackCache;
  250. _allSvgIcons = _allSvgIconsCache ?? [];
  251. });
  252. print('Icon pack and icons set in state. _allSvgIcons.length: ${_allSvgIcons.length}');
  253. }
  254. // After setting _iconPack and _allSvgIcons, always do icon assignment:
  255. if (widget.item.iconPath == null && _iconPath == null && _iconPack != null) {
  256. final autoIcon = _findIconForItem(_iconPack!);
  257. if (autoIcon != null) {
  258. setState(() {
  259. _iconPath = autoIcon;
  260. });
  261. // Persist the auto-assigned iconPath
  262. final appState = Provider.of<AppState>(context, listen: false);
  263. final items = appState.items;
  264. if (items != null) {
  265. final idx = items.indexWhere((i) => i.id == widget.item.id);
  266. if (idx != -1) {
  267. items[idx].iconPath = autoIcon;
  268. await appState.replaceItems(List.from(items));
  269. }
  270. }
  271. print('Auto-matched iconPath set: $_iconPath');
  272. }
  273. } else if (widget.item.iconPath != null && _iconPath == null) {
  274. setState(() {
  275. _iconPath = widget.item.iconPath;
  276. });
  277. print('Loaded iconPath from item: $_iconPath');
  278. }
  279. }
  280. // Helper to normalize issuer/account/icon names for matching
  281. String _normalizeIssuer(String s) {
  282. return s
  283. .toLowerCase()
  284. .replaceAll(RegExp(r'\\.com|\\.net|\\.org|\\.io|\\.co'), '')
  285. .replaceAll(RegExp(r'[^a-z0-9]'), '');
  286. }
  287. // Find icon path based on issuer/accountName with improved normalization and matching
  288. String? _findIconForItem(Map<String, dynamic> packJson) {
  289. final issuerRaw = widget.item.totp.issuer.trim();
  290. final accountRaw = widget.item.totp.accountName.trim();
  291. final issuerNorm = _normalizeIssuer(issuerRaw);
  292. final accountNorm = _normalizeIssuer(accountRaw);
  293. // print('Issuer: "$issuerRaw" (normalized: "$issuerNorm")');
  294. // print('Account: "$accountRaw" (normalized: "$accountNorm")');
  295. if (packJson['icons'] is List) {
  296. // 1. Try exact normalized match
  297. for (final icon in packJson['icons']) {
  298. if (icon is Map && icon['issuer'] is List) {
  299. for (final i in icon['issuer']) {
  300. if (i is String) {
  301. final iconIssuerNorm = _normalizeIssuer(i);
  302. if (issuerNorm == iconIssuerNorm || accountNorm == iconIssuerNorm) {
  303. // print('Exact match: $iconIssuerNorm for icon ${icon['filename']}');
  304. return p.join('graphics', 'aegis-icons-outline', icon['filename']);
  305. }
  306. }
  307. }
  308. }
  309. }
  310. // 2. Try substring match
  311. for (final icon in packJson['icons']) {
  312. if (icon is Map && icon['issuer'] is List) {
  313. for (final i in icon['issuer']) {
  314. if (i is String) {
  315. final iconIssuerNorm = _normalizeIssuer(i);
  316. if (issuerNorm.contains(iconIssuerNorm) || iconIssuerNorm.contains(issuerNorm) ||
  317. accountNorm.contains(iconIssuerNorm) || iconIssuerNorm.contains(accountNorm)) {
  318. // print('Substring match: $iconIssuerNorm for icon ${icon['filename']}');
  319. return p.join('graphics', 'aegis-icons-outline', icon['filename']);
  320. }
  321. }
  322. }
  323. }
  324. }
  325. // 3. Fallback: first letter icon if available
  326. if (issuerNorm.isNotEmpty) {
  327. final firstLetter = issuerNorm[0];
  328. for (final icon in packJson['icons']) {
  329. if (icon is Map && icon['issuer'] is List) {
  330. for (final i in icon['issuer']) {
  331. if (i is String && _normalizeIssuer(i) == firstLetter) {
  332. // print('First letter fallback: $firstLetter for icon ${icon['filename']}');
  333. return p.join('graphics', 'aegis-icons-outline', icon['filename']);
  334. }
  335. }
  336. }
  337. }
  338. }
  339. }
  340. print('No icon match found, using default.');
  341. return null;
  342. }
  343. // Helper to ensure asset path is correct
  344. String _iconPathFromAsset(String path) {
  345. return path.replaceAll('\\', '/');
  346. }
  347. void _onIconLongPress() async {
  348. if (_allSvgIcons.isEmpty) {
  349. ScaffoldMessenger.of(context).showSnackBar(
  350. SnackBar(content: Text('No icons found.')),
  351. );
  352. return;
  353. }
  354. final selected = await showDialog<String>(
  355. context: context,
  356. builder: (context) => IconSelectorDialog(allSvgIcons: _allSvgIcons, iconPack: _iconPack),
  357. );
  358. if (selected != null) {
  359. setState(() {
  360. _iconPath = selected;
  361. });
  362. // Save to storage (update AppState/repository as needed)
  363. final appState = Provider.of<AppState>(context, listen: false);
  364. final items = appState.items;
  365. if (items != null) {
  366. final idx = items.indexWhere((i) => i.id == widget.item.id);
  367. if (idx != -1) {
  368. items[idx].iconPath = selected;
  369. await appState.replaceItems(List.from(items));
  370. }
  371. }
  372. }
  373. }
  374. @override
  375. Widget build(BuildContext context) {
  376. final showIcons = Provider.of<AppState>(context, listen: false).showIcons;
  377. // Use Consumer to get forceMonochromeIcons
  378. print('BUILD: _iconPath=$_iconPath, _allSvgIcons.length=${_allSvgIcons.length}, _iconPack loaded=${_iconPack != null}');
  379. return Consumer<AppState>(
  380. builder: (context, appState, _) {
  381. final forceMono = appState.forceMonochromeIcons;
  382. return Hero(
  383. tag: widget.item.id,
  384. child: Card(
  385. child: InkWell(
  386. onTap: () {
  387. Clipboard.setData(ClipboardData(text: widget.codeUnformatted));
  388. ScaffoldMessenger.of(context).removeCurrentSnackBar();
  389. ScaffoldMessenger.of(context).showSnackBar(SnackBar(
  390. content: Text(AppLocalizations.of(context)!.clipboard),
  391. duration: const Duration(seconds: 1)));
  392. },
  393. child: Padding(
  394. padding: const EdgeInsets.all(25),
  395. child: SingleChildScrollView(
  396. padding: const EdgeInsets.all(5),
  397. child: Stack(
  398. children: <Widget>[
  399. Column(
  400. crossAxisAlignment: CrossAxisAlignment.start,
  401. children: <Widget>[
  402. // Row for icon and issuer
  403. Row(
  404. crossAxisAlignment: CrossAxisAlignment.center,
  405. children: [
  406. if (showIcons)
  407. GestureDetector(
  408. onLongPress: _onIconLongPress,
  409. child: Container(
  410. decoration: BoxDecoration(
  411. color: Theme.of(context).colorScheme.surfaceVariant,
  412. shape: BoxShape.circle,
  413. ),
  414. padding: const EdgeInsets.all(4),
  415. child: _iconPath != null
  416. ? Builder(
  417. builder: (context) {
  418. final assetPath = _iconPathFromAsset(_iconPath!);
  419. // print('Trying to load asset: ' + assetPath);
  420. return SvgPicture.asset(
  421. assetPath,
  422. height: 32,
  423. width: 32,
  424. color: forceMono ? Theme.of(context).colorScheme.onSurface : null,
  425. placeholderBuilder: (context) =>
  426. Icon(Icons.shield_outlined, size: 32),
  427. );
  428. },
  429. )
  430. : Icon(Icons.shield_outlined, size: 32),
  431. ),
  432. ),
  433. if (showIcons) const SizedBox(width: 12),
  434. Expanded(
  435. child: Text(
  436. widget.item.totp.issuer,
  437. style: Theme.of(context).textTheme.titleMedium,
  438. overflow: TextOverflow.ellipsis,
  439. ),
  440. ),
  441. ],
  442. ),
  443. // Generated code
  444. Padding(
  445. padding: const EdgeInsets.symmetric(vertical: 10),
  446. child: Text(_code,
  447. style: Theme.of(context).textTheme.displaySmall)),
  448. // Account name
  449. Text(widget.item.totp.accountName,
  450. style: Theme.of(context).textTheme.bodyMedium)
  451. ],
  452. ),
  453. // Progress indicator at bottom right
  454. Positioned(
  455. right: 0,
  456. bottom: 0,
  457. height: _progressIndicatorDimension,
  458. width: _progressIndicatorDimension,
  459. child: AnimatedBuilder(
  460. animation: _animation,
  461. builder: (BuildContext context, Widget? child) =>
  462. CircularProgressIndicator(
  463. value: _animation.value,
  464. strokeWidth: 2.5,
  465. )))
  466. ],
  467. ),
  468. ),
  469. ),
  470. ),
  471. ),
  472. );
  473. },
  474. );
  475. }
  476. }