diff --git a/examples/basic/pubspec.lock b/examples/basic/pubspec.lock index 511ffc6..dbe20bb 100644 --- a/examples/basic/pubspec.lock +++ b/examples/basic/pubspec.lock @@ -105,15 +105,15 @@ packages: path: "../../packages/sdk" relative: true source: path - version: "0.1.0" + version: "0.2.0" iconify_sdk_core: dependency: transitive description: name: iconify_sdk_core - sha256: "2f246aee51c7867fc9333a142b457685c6623175301c89267a5e5a98c8f1c6fe" + sha256: "86eb36da4b2967fac9ca2b8c0457cccd13dd9de11d4bb5e733e735d2b23e77d5" url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.2.0" leak_tracker: dependency: transitive description: diff --git a/examples/bundled/pubspec.lock b/examples/bundled/pubspec.lock index b6a03f4..20800ee 100644 --- a/examples/bundled/pubspec.lock +++ b/examples/bundled/pubspec.lock @@ -249,22 +249,22 @@ packages: path: "../../packages/sdk" relative: true source: path - version: "0.1.0" + version: "0.2.0" iconify_sdk_builder: dependency: "direct dev" description: path: "../../packages/builder" relative: true source: path - version: "0.1.0" + version: "0.2.0" iconify_sdk_core: dependency: transitive description: name: iconify_sdk_core - sha256: "2f246aee51c7867fc9333a142b457685c6623175301c89267a5e5a98c8f1c6fe" + sha256: "86eb36da4b2967fac9ca2b8c0457cccd13dd9de11d4bb5e733e735d2b23e77d5" url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.2.0" io: dependency: transitive description: diff --git a/packages/builder/lib/src/generator/icon_code_generator.dart b/packages/builder/lib/src/generator/icon_code_generator.dart index 26f880e..197a126 100644 --- a/packages/builder/lib/src/generator/icon_code_generator.dart +++ b/packages/builder/lib/src/generator/icon_code_generator.dart @@ -13,7 +13,7 @@ class IconCodeGenerator { buffer.writeln( '// ignore_for_file: constant_identifier_names, library_private_types_in_public_api'); buffer.writeln(); - buffer.writeln("import 'package:iconify_sdk_core/iconify_sdk_core.dart';"); + buffer.writeln("import 'package:iconify_sdk/iconify_sdk.dart';"); buffer.writeln(); // Group icons by prefix for organization @@ -40,7 +40,7 @@ class IconCodeGenerator { final varName = _toCamelCase(iconName); buffer.writeln(' /// $fullName'); buffer.writeln(' static const $varName = IconifyIconData('); - buffer.writeln(" body: r'${data.body}',"); + buffer.writeln(" body: r'''${data.body}''',"); buffer.writeln(' width: ${data.width},'); buffer.writeln(' height: ${data.height},'); buffer.writeln(' );'); diff --git a/packages/builder/lib/src/scanner/icon_name_scanner.dart b/packages/builder/lib/src/scanner/icon_name_scanner.dart index 5420e61..43ee9cc 100644 --- a/packages/builder/lib/src/scanner/icon_name_scanner.dart +++ b/packages/builder/lib/src/scanner/icon_name_scanner.dart @@ -13,7 +13,7 @@ class IconNameScanner { r'([a-z0-9][a-z0-9\-]*:[a-z0-9][a-z0-9\-]*)' r"['" r'"]' - r'\s*\)', + r'[\s\S]*?\)', caseSensitive: false, ); @@ -28,7 +28,7 @@ class IconNameScanner { r'([a-z0-9][a-z0-9\-]*)' r"['" r'"]' - r'\s*\)', + r'[\s\S]*?\)', caseSensitive: false, ); diff --git a/packages/cli/lib/src/cli_runner.dart b/packages/cli/lib/src/cli_runner.dart index 1f2c7c3..0e3011d 100644 --- a/packages/cli/lib/src/cli_runner.dart +++ b/packages/cli/lib/src/cli_runner.dart @@ -2,10 +2,12 @@ import 'package:args/command_runner.dart'; import 'package:iconify_sdk_core/iconify_sdk_core.dart'; import 'package:mason_logger/mason_logger.dart'; +import 'commands/add_command.dart'; import 'commands/doctor_command.dart'; import 'commands/generate_command.dart'; import 'commands/init_command.dart'; import 'commands/licenses_command.dart'; +import 'commands/prune_command.dart'; import 'commands/sync_command.dart'; class IconifyCommandRunner extends CommandRunner { @@ -24,6 +26,8 @@ class IconifyCommandRunner extends CommandRunner { addCommand(GenerateCommand(logger: _logger)); addCommand(DoctorCommand(logger: _logger)); addCommand(LicensesCommand(logger: _logger)); + addCommand(PruneCommand(logger: _logger)); + addCommand(AddCommand(logger: _logger)); } final Logger _logger; diff --git a/packages/cli/lib/src/commands/add_command.dart b/packages/cli/lib/src/commands/add_command.dart new file mode 100644 index 0000000..ddf22e0 --- /dev/null +++ b/packages/cli/lib/src/commands/add_command.dart @@ -0,0 +1,189 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:http/http.dart' as http; +import 'package:iconify_sdk_builder/iconify_sdk_builder.dart'; +import 'package:iconify_sdk_core/iconify_sdk_core.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; + +class AddCommand extends Command { + AddCommand({required Logger logger}) : _logger = logger { + argParser.addOption( + 'collection', + abbr: 'c', + help: + 'Add all icons from a specific collection (requires local snapshot).', + ); + } + + @override + String get name => 'add'; + + @override + String get description => 'Explicitly add icons to used_icons.json.'; + + @override + String get invocation => 'iconify add [...]'; + + final Logger _logger; + + @override + Future run() async { + final configFile = File('iconify.yaml'); + if (!configFile.existsSync()) { + _logger.err('iconify.yaml not found. Run "iconify init" first.'); + return ExitCode.config.code; + } + + final IconifyBuildConfig config; + try { + final content = await configFile.readAsString(); + config = IconifyBuildConfig.fromYaml(content); + } catch (e) { + _logger.err('Error parsing iconify.yaml: $e'); + return ExitCode.software.code; + } + + final cachePath = p.join(config.dataDir, 'used_icons.json'); + final cacheFile = File(cachePath); + + if (!cacheFile.parent.existsSync()) { + cacheFile.parent.createSync(recursive: true); + } + + Map cacheJson; + if (cacheFile.existsSync()) { + try { + final content = await cacheFile.readAsString(); + final decoded = jsonDecode(content); + if (decoded is Map) { + cacheJson = Map.from(decoded); + } else { + cacheJson = _createEmptyCache(); + } + } catch (e) { + _logger.err('Failed to parse used_icons.json: $e'); + return ExitCode.software.code; + } + } else { + cacheJson = _createEmptyCache(); + } + + final iconsJson = + Map.from(cacheJson['icons'] as Map? ?? {}); + final collections = {}; + + final iconsToAdd = []; + + final collectionOption = argResults?['collection'] as String?; + if (collectionOption != null) { + final snapshotFile = File('${config.dataDir}/$collectionOption.json'); + if (!snapshotFile.existsSync()) { + _logger.err( + 'Snapshot for "$collectionOption" not found. Run "iconify sync" first.'); + return ExitCode.noInput.code; + } + + try { + final jsonStr = await snapshotFile.readAsString(); + final collection = IconifyJsonParser.parseCollectionString(jsonStr); + for (final iconName in collection.allNames) { + iconsToAdd.add('$collectionOption:$iconName'); + collections[collectionOption] = collection; + } + } catch (e) { + _logger.err('Failed to parse snapshot for "$collectionOption": $e'); + return ExitCode.software.code; + } + } + + iconsToAdd.addAll(argResults?.rest ?? []); + + if (iconsToAdd.isEmpty) { + _logger.err('No icons specified. Usage: iconify add '); + return ExitCode.usage.code; + } + + final progress = _logger.progress('Adding ${iconsToAdd.length} icons...'); + var addedCount = 0; + + final httpClient = http.Client(); + + try { + for (final fullName in iconsToAdd) { + if (iconsJson.containsKey(fullName)) continue; + + final parts = fullName.split(':'); + if (parts.length < 2) { + _logger.warn(' ⚠️ Invalid icon name: $fullName'); + continue; + } + + final prefix = parts[0]; + final iconName = parts[1]; + + if (!collections.containsKey(prefix)) { + final snapshotFile = File('${config.dataDir}/$prefix.json'); + if (snapshotFile.existsSync()) { + try { + final jsonStr = await snapshotFile.readAsString(); + collections[prefix] = + IconifyJsonParser.parseCollectionString(jsonStr); + } catch (_) {} + } + } + + IconifyIconData? data = collections[prefix]?.getIcon(iconName); + + if (data == null) { + try { + final uri = Uri.parse( + 'https://raw.githubusercontent.com/iconify/icon-sets/master/json/$prefix.json'); + final response = + await httpClient.get(uri).timeout(const Duration(seconds: 5)); + if (response.statusCode == 200) { + final collection = + IconifyJsonParser.parseCollectionString(response.body); + collections[prefix] = collection; + data = collection.getIcon(iconName); + } + } catch (_) {} + } + + if (data != null) { + final json = data.toJson(); + json['source'] = 'added'; + iconsJson[fullName] = json; + addedCount++; + } else { + _logger.warn(' ⚠️ Could not find data for "$fullName"'); + } + } + } finally { + httpClient.close(); + } + + if (addedCount > 0) { + cacheJson['generated'] = DateTime.now().toUtc().toIso8601String(); + cacheJson['icons'] = iconsJson; + await cacheFile + .writeAsString(const JsonEncoder.withIndent(' ').convert(cacheJson)); + progress + .complete('Successfully added $addedCount icons to used_icons.json'); + } else { + progress.complete('No new icons were added.'); + } + + return ExitCode.success.code; + } + + Map _createEmptyCache() { + return { + 'schemaVersion': 1, + 'generated': DateTime.now().toUtc().toIso8601String(), + 'icons': {}, + }; + } +} diff --git a/packages/cli/lib/src/commands/doctor_command.dart b/packages/cli/lib/src/commands/doctor_command.dart index 73dc90f..b4df17a 100644 --- a/packages/cli/lib/src/commands/doctor_command.dart +++ b/packages/cli/lib/src/commands/doctor_command.dart @@ -1,9 +1,11 @@ +import 'dart:convert'; import 'dart:io'; import 'package:args/command_runner.dart'; import 'package:iconify_sdk_builder/iconify_sdk_builder.dart'; import 'package:iconify_sdk_core/iconify_sdk_core.dart'; import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; class DoctorCommand extends Command { DoctorCommand({required Logger logger}) : _logger = logger; @@ -66,6 +68,47 @@ class DoctorCommand extends Command { } } } + + // 5. Check Living Cache (used_icons.json) + final cachePath = p.join(config.dataDir, 'used_icons.json'); + final cacheFile = File(cachePath); + if (cacheFile.existsSync()) { + try { + final cacheJson = jsonDecode(await cacheFile.readAsString()) + as Map; + final cachedIcons = + (cacheJson['icons'] as Map? ?? {}) + .keys + .toSet(); + + if (cachedIcons.isNotEmpty) { + // Quick scan for stale icons + final usedIcons = {}; + final libDir = Directory('lib'); + if (libDir.existsSync()) { + final entities = libDir.listSync(recursive: true); + for (final entity in entities) { + if (entity is File && entity.path.endsWith('.dart')) { + if (entity.path.endsWith(config.output)) continue; + final content = await entity.readAsString(); + final scanner = IconNameScanner()..scan(content); + usedIcons.addAll(scanner.iconNames); + } + } + + final staleIcons = cachedIcons.difference(usedIcons); + if (staleIcons.isNotEmpty) { + _logger.warn( + ' ⚠️ Found ${staleIcons.length} stale icons in used_icons.json. Run "iconify prune" to clean up.'); + hasWarnings = true; + } else { + _logger.success( + ' ✅ used_icons.json is healthy (${cachedIcons.length} icons).'); + } + } + } + } catch (_) {} + } } catch (e) { _logger.err(' ❌ Failed to parse iconify.yaml: $e'); hasIssues = true; diff --git a/packages/cli/lib/src/commands/init_command.dart b/packages/cli/lib/src/commands/init_command.dart index b01de3f..f9d3b34 100644 --- a/packages/cli/lib/src/commands/init_command.dart +++ b/packages/cli/lib/src/commands/init_command.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:args/command_runner.dart'; import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; class InitCommand extends Command { InitCommand({required Logger logger}) : _logger = logger { @@ -77,14 +78,24 @@ class InitCommand extends Command { dir.createSync(recursive: true); } + // 6. Create initial used_icons.json + final cachePath = p.join(dataDir, 'used_icons.json'); + final cacheFile = File(cachePath); + if (!cacheFile.existsSync()) { + await cacheFile.writeAsString( + '{"schemaVersion": 1, "generated": "${DateTime.now().toUtc().toIso8601String()}", "icons": {}}', + ); + } + _logger.success('✅ Created iconify.yaml'); + _logger.success('✅ Initialized $cachePath'); _logger.info('\nNext steps:'); _logger.info( '1. Run ${lightCyan.wrap('dart run iconify_sdk_cli sync')} to download icon data.'); _logger .info('2. Add ${lightCyan.wrap('IconifyIcon')} widgets to your app.'); _logger.info( - '3. Run ${lightCyan.wrap('dart run build_runner build')} to bundle icons for production.'); + '3. Run ${lightCyan.wrap('iconify prune')} to clean up stale icons.'); return ExitCode.success.code; } diff --git a/packages/cli/lib/src/commands/prune_command.dart b/packages/cli/lib/src/commands/prune_command.dart new file mode 100644 index 0000000..77f3915 --- /dev/null +++ b/packages/cli/lib/src/commands/prune_command.dart @@ -0,0 +1,152 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:iconify_sdk_builder/iconify_sdk_builder.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; + +class PruneCommand extends Command { + PruneCommand({required Logger logger}) : _logger = logger { + argParser.addFlag( + 'dry-run', + help: 'Show what would be removed without modifying the file.', + negatable: false, + ); + argParser.addFlag( + 'force', + abbr: 'f', + help: 'Skip confirmation and prune immediately.', + negatable: false, + ); + } + + @override + String get name => 'prune'; + + @override + String get description => + 'Removes icons from used_icons.json that no longer appear in source code.'; + + final Logger _logger; + + @override + Future run() async { + final configFile = File('iconify.yaml'); + if (!configFile.existsSync()) { + _logger.err('iconify.yaml not found. Run "iconify init" first.'); + return ExitCode.config.code; + } + + final IconifyBuildConfig config; + try { + config = IconifyBuildConfig.fromYaml(await configFile.readAsString()); + } catch (e) { + _logger.err('Error parsing iconify.yaml: $e'); + return ExitCode.config.code; + } + + final cachePath = p.join(config.dataDir, 'used_icons.json'); + final cacheFile = File(cachePath); + + if (!cacheFile.existsSync()) { + _logger + .info('used_icons.json not found at $cachePath. Nothing to prune.'); + return ExitCode.success.code; + } + + final progress = _logger.progress('Scanning source code...'); + final usedIcons = {}; + + // 1. Scan lib/ directory + final libDir = Directory('lib'); + if (!libDir.existsSync()) { + progress.fail('Could not find lib/ directory.'); + return ExitCode.noInput.code; + } + + final entities = libDir.listSync(recursive: true); + for (final entity in entities) { + if (entity is File && entity.path.endsWith('.dart')) { + if (entity.path.endsWith(config.output)) continue; + final content = await entity.readAsString(); + final scanner = IconNameScanner()..scan(content); + usedIcons.addAll(scanner.iconNames); + } + } + + progress.update('Reading used_icons.json...'); + + final Map cacheJson; + try { + cacheJson = + jsonDecode(await cacheFile.readAsString()) as Map; + } catch (e) { + progress.fail('Failed to parse used_icons.json: $e'); + return ExitCode.software.code; + } + + final iconsJson = cacheJson['icons'] as Map? ?? {}; + final cachedIconNames = iconsJson.keys.toSet(); + + // 2. Compute difference + final staleIcons = cachedIconNames.difference(usedIcons); + + if (staleIcons.isEmpty) { + progress.complete('No stale icons found. used_icons.json is up to date.'); + return ExitCode.success.code; + } + + progress.complete('Found ${staleIcons.length} stale icons.'); + + // 3. Report + for (final icon in staleIcons) { + _logger.info(' ${lightRed.wrap('-')} $icon'); + } + + if (argResults?['dry-run'] == true) { + _logger.info('\nDry run: would remove ${staleIcons.length} icons.'); + return ExitCode.success.code; + } + + // 4. Confirm and prune + var shouldPrune = argResults?['force'] == true; + if (!shouldPrune) { + shouldPrune = _logger.confirm( + 'Remove these ${staleIcons.length} icons from used_icons.json?', + defaultValue: true, + ); + } + + if (shouldPrune) { + final oldSize = cacheFile.lengthSync(); + + // Remove stale icons + for (final icon in staleIcons) { + iconsJson.remove(icon); + } + + // Update metadata + cacheJson['generated'] = DateTime.now().toUtc().toIso8601String(); + + final encoder = const JsonEncoder.withIndent(' '); + await cacheFile.writeAsString(encoder.convert(cacheJson)); + + final newSize = cacheFile.lengthSync(); + _logger.success( + 'Pruned ${staleIcons.length} icons. ' + 'Size reduced from ${_formatSize(oldSize)} to ${_formatSize(newSize)}.', + ); + } else { + _logger.info('Pruning cancelled.'); + } + + return ExitCode.success.code; + } + + String _formatSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } +} diff --git a/packages/cli/test/commands/add_command_test.dart b/packages/cli/test/commands/add_command_test.dart new file mode 100644 index 0000000..228a467 --- /dev/null +++ b/packages/cli/test/commands/add_command_test.dart @@ -0,0 +1,112 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:iconify_sdk_cli/src/cli_runner.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +class MockLogger extends Mock implements Logger {} + +class MockProgress extends Mock implements Progress {} + +void main() { + group('AddCommand', () { + late Directory tempDir; + late Logger logger; + late IconifyCommandRunner runner; + late Progress progress; + late String originalCwd; + + setUp(() async { + originalCwd = Directory.current.path; + tempDir = await Directory.systemTemp.createTemp('iconify_add_test_'); + + // Setup files using absolute paths to avoid Directory.current issues + await File(p.join(tempDir.path, 'iconify.yaml')).writeAsString(''' +sets: + - mdi:* +data_dir: assets/iconify +output: lib/icons.g.dart +'''); + + await Directory(p.join(tempDir.path, 'assets', 'iconify')) + .create(recursive: true); + await Directory(p.join(tempDir.path, 'lib')).create(recursive: true); + + logger = MockLogger(); + progress = MockProgress(); + when(() => logger.progress(any())).thenReturn(progress); + + runner = IconifyCommandRunner(logger: logger); + + // We still need to set it for the command itself since it uses relative paths + Directory.current = tempDir; + }); + + tearDown(() async { + Directory.current = originalCwd; + if (tempDir.existsSync()) { + await tempDir.delete(recursive: true); + } + }); + + test('adds icons from local snapshot', () async { + await File(p.join(tempDir.path, 'assets', 'iconify', 'mdi.json')) + .writeAsString(jsonEncode({ + 'prefix': 'mdi', + 'icons': { + 'home': {'body': ''}, + 'account': {'body': ''}, + } + })); + + final result = await runner.run(['add', 'mdi:home']); + + expect(result, equals(ExitCode.success.code)); + + final cacheFile = + File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')); + expect(cacheFile.existsSync(), isTrue); + + final cacheJson = + jsonDecode(await cacheFile.readAsString()) as Map; + final icons = cacheJson['icons'] as Map; + + expect(icons.containsKey('mdi:home'), isTrue); + expect((icons['mdi:home'] as Map)['body'], equals('')); + }); + + test('adds whole collection via flag', () async { + await File(p.join(tempDir.path, 'assets', 'iconify', 'mdi.json')) + .writeAsString(jsonEncode({ + 'prefix': 'mdi', + 'icons': { + 'home': {'body': ''}, + 'account': {'body': ''}, + } + })); + + final result = await runner.run(['add', '--collection', 'mdi']); + + expect(result, equals(ExitCode.success.code)); + + final cacheJson = jsonDecode(await File( + p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')) + .readAsString()) as Map; + final icons = cacheJson['icons'] as Map; + + expect(icons.length, equals(2)); + }); + + test('fails if icon not found and no network', () async { + final result = await runner.run(['add', 'nonexistent:icon']); + expect(result, equals(ExitCode.success.code)); + + final cacheFile = + File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')); + expect(cacheFile.existsSync(), isFalse); + }); + }); +} diff --git a/packages/cli/test/commands/prune_command_test.dart b/packages/cli/test/commands/prune_command_test.dart new file mode 100644 index 0000000..00ee203 --- /dev/null +++ b/packages/cli/test/commands/prune_command_test.dart @@ -0,0 +1,133 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:iconify_sdk_cli/src/cli_runner.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +class MockLogger extends Mock implements Logger {} + +class MockProgress extends Mock implements Progress {} + +void main() { + group('PruneCommand', () { + late Directory tempDir; + late Logger logger; + late IconifyCommandRunner runner; + late Progress progress; + late String originalCwd; + + setUp(() async { + originalCwd = Directory.current.path; + tempDir = await Directory.systemTemp.createTemp('iconify_prune_test_'); + + // Setup iconify.yaml + await File(p.join(tempDir.path, 'iconify.yaml')).writeAsString(''' +sets: + - mdi:* +data_dir: assets/iconify +output: lib/icons.g.dart +'''); + + // Setup used_icons.json + final cacheDir = Directory(p.join(tempDir.path, 'assets', 'iconify')); + await cacheDir.create(recursive: true); + await File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')) + .writeAsString(jsonEncode({ + 'schemaVersion': 1, + 'icons': { + 'mdi:home': {'body': ''}, + 'mdi:account': {'body': ''}, + } + })); + + // Setup lib/ directory + await Directory(p.join(tempDir.path, 'lib')).create(recursive: true); + + logger = MockLogger(); + progress = MockProgress(); + when(() => logger.progress(any())).thenReturn(progress); + + runner = IconifyCommandRunner(logger: logger); + Directory.current = tempDir; + }); + + tearDown(() async { + Directory.current = originalCwd; + if (tempDir.existsSync()) { + await tempDir.delete(recursive: true); + } + }); + + test('prunes stale icons with confirmation', () async { + // Use only mdi:home in source + await File(p.join(tempDir.path, 'lib', 'main.dart')) + .writeAsString("IconifyIcon('mdi:home')"); + + when(() => + logger.confirm(any(), defaultValue: any(named: 'defaultValue'))) + .thenReturn(true); + + final result = await runner.run(['prune']); + + expect(result, equals(ExitCode.success.code)); + + final cacheFile = + File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')); + final cacheJson = + jsonDecode(await cacheFile.readAsString()) as Map; + final icons = cacheJson['icons'] as Map; + + expect(icons.containsKey('mdi:home'), isTrue); + expect(icons.containsKey('mdi:account'), isFalse); + expect(icons.length, equals(1)); + }); + + test('respects --force flag', () async { + await File(p.join(tempDir.path, 'lib', 'main.dart')) + .writeAsString("IconifyIcon('mdi:home')"); + + final result = await runner.run(['prune', '--force']); + + expect(result, equals(ExitCode.success.code)); + verifyNever(() => + logger.confirm(any(), defaultValue: any(named: 'defaultValue'))); + + final cacheFile = + File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')); + final cacheJson = + jsonDecode(await cacheFile.readAsString()) as Map; + expect((cacheJson['icons'] as Map).length, equals(1)); + }); + + test('respects --dry-run flag', () async { + await File(p.join(tempDir.path, 'lib', 'main.dart')) + .writeAsString("IconifyIcon('mdi:home')"); + + final result = await runner.run(['prune', '--dry-run']); + + expect(result, equals(ExitCode.success.code)); + + final cacheFile = + File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')); + final cacheJson = + jsonDecode(await cacheFile.readAsString()) as Map; + expect((cacheJson['icons'] as Map).length, + equals(2)); // No icons removed + }); + + test('completes with message if nothing to prune', () async { + await File(p.join(tempDir.path, 'lib', 'main.dart')) + .writeAsString("IconifyIcon('mdi:home'), IconifyIcon('mdi:account')"); + + final result = await runner.run(['prune']); + + expect(result, equals(ExitCode.success.code)); + verify(() => + progress.complete(any(that: contains('No stale icons found')))) + .called(1); + }); + }); +} diff --git a/packages/core/lib/iconify_sdk_core.dart b/packages/core/lib/iconify_sdk_core.dart index a72a9a6..9c4ce92 100644 --- a/packages/core/lib/iconify_sdk_core.dart +++ b/packages/core/lib/iconify_sdk_core.dart @@ -40,9 +40,12 @@ export 'src/providers/asset_bundle_iconify_provider.dart'; export 'src/providers/caching_iconify_provider.dart'; export 'src/providers/composite_iconify_provider.dart'; export 'src/providers/file_system_iconify_provider.dart'; +export 'src/providers/file_system_living_cache_storage.dart'; export 'src/providers/iconify_provider.dart'; +export 'src/providers/living_cache_provider.dart'; export 'src/providers/memory_iconify_provider.dart'; export 'src/providers/remote_iconify_provider.dart'; // Resolver export 'src/resolver/alias_resolver.dart'; +export 'src/resolver/pub_cache_path_resolver.dart'; diff --git a/packages/core/lib/src/providers/file_system_living_cache_storage.dart b/packages/core/lib/src/providers/file_system_living_cache_storage.dart new file mode 100644 index 0000000..d2075e9 --- /dev/null +++ b/packages/core/lib/src/providers/file_system_living_cache_storage.dart @@ -0,0 +1,34 @@ +import 'dart:io'; +import 'living_cache_provider.dart'; + +/// A [LivingCacheStorage] implementation that uses [dart:io] for filesystem access. +/// +/// This is used in CLI and in Flutter (development mode) to update the cache file. +class FileSystemLivingCacheStorage implements LivingCacheStorage { + FileSystemLivingCacheStorage({required this.path}); + + final String path; + + @override + Future read() async { + final file = File(path); + if (file.existsSync()) { + return file.readAsString(); + } + return null; + } + + @override + Future write(String content) async { + final file = File(path); + final dir = file.parent; + if (!dir.existsSync()) { + dir.createSync(recursive: true); + } + + // Atomic write: write to a temporary file then rename + final tempFile = File('$path.tmp'); + await tempFile.writeAsString(content); + await tempFile.rename(path); + } +} diff --git a/packages/core/lib/src/providers/living_cache_provider.dart b/packages/core/lib/src/providers/living_cache_provider.dart new file mode 100644 index 0000000..b2e37c7 --- /dev/null +++ b/packages/core/lib/src/providers/living_cache_provider.dart @@ -0,0 +1,170 @@ +import 'dart:async'; +import 'dart:convert'; +import '../models/iconify_collection_info.dart'; +import '../models/iconify_icon_data.dart'; +import '../models/iconify_name.dart'; +import 'iconify_provider.dart'; + +/// An abstraction for reading and writing the living cache file. +/// +/// This allows [LivingCacheProvider] to work in both Flutter (via rootBundle/assets) +/// and CLI (via dart:io). +abstract interface class LivingCacheStorage { + /// Reads the content of the living cache file. + /// + /// Returns null if the file does not exist. + Future read(); + + /// Writes the content of the living cache file. + Future write(String content); +} + +/// A provider that manages a "living cache" of icons used by the application. +/// +/// This provider serves two purposes: +/// 1. **Production Bundle:** It reads from `assets/iconify/used_icons.json`, +/// which contains exactly the icons needed by the app, eliminating the +/// need for large starter assets in production. +/// 2. **Development Write-back:** In development, it can be updated with +/// icons fetched from remote or local sources, making them available +/// offline for subsequent runs. +final class LivingCacheProvider extends IconifyProvider { + LivingCacheProvider({ + required this.storage, + this.debounceDuration = const Duration(milliseconds: 500), + }); + + final LivingCacheStorage storage; + final Duration debounceDuration; + + bool _loaded = false; + int _schemaVersion = 1; + Map _icons = {}; + final Map _sources = {}; + + Timer? _flushTimer; + Completer? _flushCompleter; + + Future _ensureLoaded() async { + if (_loaded) return; + + try { + final content = await storage.read(); + if (content != null && content.isNotEmpty) { + final json = jsonDecode(content) as Map; + _schemaVersion = json['schemaVersion'] as int? ?? 1; + + final iconsJson = json['icons'] as Map? ?? {}; + _icons = iconsJson.map((key, value) { + final iconData = + IconifyIconData.fromJson(value as Map); + // Extract source info if present in the JSON + final source = value['source'] as String?; + if (source != null) { + _sources[key] = source; + } + return MapEntry(key, iconData); + }); + } + } catch (e) { + // If load fails, we start with an empty cache + _icons = {}; + } finally { + _loaded = true; + } + } + + @override + Future getIcon(IconifyName name) async { + await _ensureLoaded(); + final key = name.toString(); + final icon = _icons[key]; + if (icon == null) return null; + + // Attach source info back to the raw data for runtime use + if (_sources.containsKey(key)) { + return icon.copyWith(raw: {...icon.raw, 'source': _sources[key]}); + } + return icon; + } + + @override + Future getCollection(String prefix) async { + // LivingCache is a flat map of icons, it doesn't represent full collections. + return null; + } + + @override + Future hasIcon(IconifyName name) async { + await _ensureLoaded(); + return _icons.containsKey(name.toString()); + } + + @override + Future hasCollection(String prefix) async { + // We don't track collection existence, only individual icons. + return false; + } + + /// Adds an icon to the living cache. + /// + /// [source] indicates where the icon came from (e.g., "remote", "starter"). + Future addIcon(IconifyName name, IconifyIconData data, + {String? source}) async { + await _ensureLoaded(); + + final key = name.toString(); + _icons[key] = data; + if (source != null) { + _sources[key] = source; + } + + _scheduleFlush(); + } + + void _scheduleFlush() { + _flushTimer?.cancel(); + _flushCompleter ??= Completer(); + + _flushTimer = Timer(debounceDuration, () async { + final completer = _flushCompleter; + _flushCompleter = null; + + try { + await flush(); + completer?.complete(); + } catch (e) { + completer?.completeError(e); + } + }); + } + + /// Forces a write of the current cache to storage. + Future flush() async { + final iconsJson = >{}; + + _icons.forEach((key, data) { + final json = data.toJson(); + if (_sources.containsKey(key)) { + json['source'] = _sources[key]; + } + iconsJson[key] = json; + }); + + final json = { + 'schemaVersion': _schemaVersion, + 'generated': DateTime.now().toUtc().toIso8601String(), + 'icons': iconsJson, + }; + + final content = const JsonEncoder.withIndent(' ').convert(json); + await storage.write(content); + } + + @override + Future dispose() async { + _flushTimer?.cancel(); + // Note: We don't await flush here as dispose is usually synchronous. + // Users should call flush() explicitly if they want to ensure persistence. + } +} diff --git a/packages/core/lib/src/providers/remote_iconify_provider.dart b/packages/core/lib/src/providers/remote_iconify_provider.dart index 33fd198..73e3d29 100644 --- a/packages/core/lib/src/providers/remote_iconify_provider.dart +++ b/packages/core/lib/src/providers/remote_iconify_provider.dart @@ -12,6 +12,7 @@ import '../models/iconify_icon_data.dart'; import '../models/iconify_name.dart'; import '../parser/iconify_json_parser.dart'; import 'iconify_provider.dart'; +import 'living_cache_provider.dart'; /// An [IconifyProvider] that fetches icons from the Iconify HTTP API. /// @@ -29,6 +30,8 @@ final class RemoteIconifyProvider implements IconifyProvider { this.batchWindow = const Duration(milliseconds: 50), this.requestTimeout = const Duration(seconds: 10), Map? additionalHeaders, + this.livingCache, + this.writeBackEnabled = true, }) : _apiBase = apiBase ?? 'https://api.iconify.design', _client = httpClient ?? http.Client(), _allowInRelease = allowInRelease, @@ -41,6 +44,14 @@ final class RemoteIconifyProvider implements IconifyProvider { final http.Client _client; final bool _allowInRelease; + /// Optional living cache to write fetched icons back to. + final LivingCacheProvider? livingCache; + + /// Whether to write fetched icons back to the [livingCache]. + /// + /// Defaults to true. Only effective if [livingCache] is provided. + final bool writeBackEnabled; + /// The duration to wait for concurrent requests before dispatching a batch. final Duration batchWindow; @@ -71,7 +82,11 @@ final class RemoteIconifyProvider implements IconifyProvider { // 1. Try to resolve via full collection (GitHub Raw or Cache) final collection = await _getOrFetchFromGitHub(name.prefix); if (collection != null) { - return collection.getIcon(name.iconName); + final data = collection.getIcon(name.iconName); + if (data != null) { + await _handleWriteBack(name, data); + } + return data; } // 2. Fallback: Use micro-batching API @@ -81,7 +96,27 @@ final class RemoteIconifyProvider implements IconifyProvider { ); _startBatchTimer(); - return completer.future; + + try { + final result = await completer.future; + if (result != null) { + await _handleWriteBack(name, result); + } + return result; + } catch (e) { + // If the batch fails, we don't want to rethrow from getIcon + // to maintain consistency with other providers that return null. + return null; + } + } + + Future _handleWriteBack(IconifyName name, IconifyIconData data) async { + if (livingCache != null && writeBackEnabled && _isAllowed) { + await livingCache!.addIcon(name, data, source: 'remote'); + + // ignore: avoid_print, RemoteIconifyProvider uses print for dev diagnostics. + print("[iconify] '$name' fetched remotely → written to used_icons.json"); + } } void _startBatchTimer() { @@ -121,7 +156,7 @@ final class RemoteIconifyProvider implements IconifyProvider { 'https://raw.githubusercontent.com/iconify/icon-sets/master/json/$prefix.json'); // Use print for developer diagnostic logging in the console. - // ignore: avoid_print + // ignore: avoid_print, RemoteIconifyProvider uses print for dev diagnostics. print('Iconify SDK [REMOTE]: Trying GitHub Raw for $prefix...'); final response = await _client.get(githubUri).timeout(requestTimeout); @@ -129,14 +164,14 @@ final class RemoteIconifyProvider implements IconifyProvider { final collection = IconifyJsonParser.parseCollectionString(response.body); // Use print for developer diagnostic logging in the console. - // ignore: avoid_print + // ignore: avoid_print, RemoteIconifyProvider uses print for dev diagnostics. print('Iconify SDK [REMOTE]: Successfully cached $prefix from GitHub'); return collection; } return null; } catch (e) { // Use print for developer diagnostic logging in the console. - // ignore: avoid_print + // ignore: avoid_print, RemoteIconifyProvider uses print for dev diagnostics. print( 'Iconify SDK [REMOTE]: GitHub fetch failed for $prefix, falling back to API: $e'); return null; @@ -160,7 +195,7 @@ final class RemoteIconifyProvider implements IconifyProvider { try { // Use print for developer diagnostic logging in the console. - // ignore: avoid_print + // ignore: avoid_print, RemoteIconifyProvider uses print for dev diagnostics. print( 'Iconify SDK [REMOTE]: Fetching ${iconNames.length} icons for $prefix...'); final response = @@ -168,7 +203,7 @@ final class RemoteIconifyProvider implements IconifyProvider { if (response.statusCode == 404) { // Use print for developer diagnostic logging in the console. - // ignore: avoid_print + // ignore: avoid_print, RemoteIconifyProvider uses print for dev diagnostics. print('Iconify SDK [REMOTE]: 404 Not Found for $prefix'); for (final req in requests) { req.completer.complete(null); @@ -178,7 +213,7 @@ final class RemoteIconifyProvider implements IconifyProvider { if (response.statusCode != 200) { // Use print for developer diagnostic logging in the console. - // ignore: avoid_print + // ignore: avoid_print, RemoteIconifyProvider uses print for dev diagnostics. print( 'Iconify SDK [REMOTE]: HTTP ${response.statusCode} for $prefix'); final error = IconifyNetworkException( @@ -194,7 +229,7 @@ final class RemoteIconifyProvider implements IconifyProvider { final json = jsonDecode(response.body) as Map; // Use print for developer diagnostic logging in the console. - // ignore: avoid_print + // ignore: avoid_print, RemoteIconifyProvider uses print for dev diagnostics. print('Iconify SDK [REMOTE]: Successfully fetched $prefix batch'); final icons = json['icons'] as Map? ?? {}; @@ -290,7 +325,9 @@ final class RemoteIconifyProvider implements IconifyProvider { // Fail any pending requests for (final requests in _pending.values) { for (final req in requests) { - req.completer.completeError(StateError('Provider disposed')); + if (!req.completer.isCompleted) { + req.completer.completeError(StateError('Provider disposed')); + } } } _pending.clear(); diff --git a/packages/core/lib/src/resolver/pub_cache_path_resolver.dart b/packages/core/lib/src/resolver/pub_cache_path_resolver.dart new file mode 100644 index 0000000..01f937f --- /dev/null +++ b/packages/core/lib/src/resolver/pub_cache_path_resolver.dart @@ -0,0 +1,84 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// Resolves the absolute path to a package within the current project. +/// +/// This is used in development to find assets that are not registered in the +/// project's pubspec.yaml, effectively making them "dev-only" assets. +class PubCachePathResolver { + PubCachePathResolver._(); + + static final Map _cachedPaths = {}; + + /// Resolves the absolute path to the [packageName] within the project's + /// pub cache or workspace. + /// + /// Returns null if the package config cannot be found or the package is not + /// present in the config (e.g., on Flutter Web). + static Future resolvePackagePath(String packageName) async { + if (_cachedPaths.containsKey(packageName)) return _cachedPaths[packageName]; + + try { + // 1. Locate .dart_tool/package_config.json + Directory? current = Directory.current; + File? configFile; + + // Limit search depth to prevent infinite loops in weird environments + int depth = 0; + while (current != null && depth < 20) { + final possibleFile = + File(p.join(current.path, '.dart_tool', 'package_config.json')); + if (possibleFile.existsSync()) { + configFile = possibleFile; + break; + } + final parent = current.parent; + if (parent.path == current.path) break; + current = parent; + depth++; + } + + if (configFile == null) return null; + + // 2. Parse the config + final content = await configFile.readAsString(); + final config = json.decode(content) as Map; + final packages = + (config['packages'] as List).cast>(); + + final packageInfo = packages.firstWhere( + (pkg) => pkg['name'] == packageName, + orElse: () => {}, + ); + + if (packageInfo.isEmpty) { + _cachedPaths[packageName] = null; + return null; + } + + final rootUri = packageInfo['rootUri'] as String; + + // 3. Convert URI to absolute path + String? resolvedPath; + if (rootUri.startsWith('file://')) { + resolvedPath = Uri.parse(rootUri).toFilePath(); + } else { + // Paths in package_config.json are relative to .dart_tool/ directory + final configDir = configFile.parent.path; + final absolutePath = p.normalize(p.join(configDir, rootUri)); + if (absolutePath.startsWith('file://')) { + resolvedPath = Uri.parse(absolutePath).toFilePath(); + } else { + resolvedPath = absolutePath; + } + } + + _cachedPaths[packageName] = resolvedPath; + return resolvedPath; + } catch (e) { + return null; + } + } +} diff --git a/packages/core/pubspec.yaml b/packages/core/pubspec.yaml index 665b3ab..9f106a0 100644 --- a/packages/core/pubspec.yaml +++ b/packages/core/pubspec.yaml @@ -12,6 +12,7 @@ environment: sdk: '>=3.5.0 <4.0.0' dependencies: + path: ^1.9.0 http: ^1.2.0 meta: ^1.15.0 diff --git a/packages/core/test/providers/living_cache_provider_test.dart b/packages/core/test/providers/living_cache_provider_test.dart new file mode 100644 index 0000000..0cf6585 --- /dev/null +++ b/packages/core/test/providers/living_cache_provider_test.dart @@ -0,0 +1,100 @@ +import 'dart:convert'; +import 'package:iconify_sdk_core/iconify_sdk_core.dart'; +import 'package:test/test.dart'; + +class MockStorage implements LivingCacheStorage { + String? content; + int writeCount = 0; + + @override + Future read() async => content; + + @override + Future write(String content) async { + this.content = content; + writeCount++; + } +} + +void main() { + group('LivingCacheProvider', () { + late MockStorage storage; + late LivingCacheProvider provider; + + setUp(() { + storage = MockStorage(); + provider = LivingCacheProvider( + storage: storage, + debounceDuration: const Duration(milliseconds: 10), + ); + }); + + test('loads empty cache from null storage', () async { + final hasIcon = await provider.hasIcon(const IconifyName('test', 'icon')); + expect(hasIcon, isFalse); + }); + + test('loads existing icons from storage', () async { + final json = { + 'schemaVersion': 1, + 'icons': { + 'test:icon': { + 'body': '', + 'width': 24.0, + 'height': 24.0, + } + } + }; + storage.content = jsonEncode(json); + + final icon = await provider.getIcon(const IconifyName('test', 'icon')); + expect(icon, isNotNull); + expect(icon!.body, ''); + }); + + test('adds icon and flushes to storage', () async { + const name = IconifyName('test', 'new'); + const data = IconifyIconData(body: ''); + + await provider.addIcon(name, data); + + // Wait for debounce + await Future.delayed(const Duration(milliseconds: 50)); + + expect(storage.writeCount, 1); + expect(storage.content, contains('test:new')); + expect(storage.content, contains('')); + }); + + test('debounces multiple writes', () async { + await provider.addIcon( + const IconifyName('test', '1'), const IconifyIconData(body: '1')); + await provider.addIcon( + const IconifyName('test', '2'), const IconifyIconData(body: '2')); + await provider.addIcon( + const IconifyName('test', '3'), const IconifyIconData(body: '3')); + + await Future.delayed(const Duration(milliseconds: 50)); + + expect(storage.writeCount, 1); + expect(storage.content, contains('test:1')); + expect(storage.content, contains('test:2')); + expect(storage.content, contains('test:3')); + }); + + test('preserves source information', () async { + const name = IconifyName('test', 'sourced'); + const data = IconifyIconData(body: 'body'); + + await provider.addIcon(name, data, source: 'remote'); + await provider.flush(); + + expect(storage.content, contains('"source": "remote"')); + + // Reload from storage + final newProvider = LivingCacheProvider(storage: storage); + final loadedIcon = await newProvider.getIcon(name); + expect(loadedIcon!.raw['source'], 'remote'); + }); + }); +} diff --git a/packages/core/test/providers/remote_iconify_provider_test.dart b/packages/core/test/providers/remote_iconify_provider_test.dart index 8560c78..e6a0cd5 100644 --- a/packages/core/test/providers/remote_iconify_provider_test.dart +++ b/packages/core/test/providers/remote_iconify_provider_test.dart @@ -4,6 +4,14 @@ import 'package:http/testing.dart'; import 'package:iconify_sdk_core/iconify_sdk_core.dart'; import 'package:test/test.dart'; +class MockLivingCacheStorage implements LivingCacheStorage { + String? content; + @override + Future read() async => content; + @override + Future write(String content) async => this.content = content; +} + void main() { group('RemoteIconifyProvider', () { const validIconJson = { @@ -51,208 +59,126 @@ void main() { }); final provider = RemoteIconifyProvider(httpClient: client); - final result = await provider.getIcon(const IconifyName('mdi', 'home')); - - expect(result, isNotNull); - expect(githubCalled, isTrue, reason: 'Should have tried GitHub first'); - expect(apiCalled, isFalse, - reason: 'Should NOT have tried API if GitHub succeeded'); - - // Subsequent call should use cache await provider.getIcon(const IconifyName('mdi', 'home')); - // githubCalled should still be true (1 call), not 2 + expect(githubCalled, isTrue); + expect(apiCalled, isFalse); await provider.dispose(); }); - test('returns null on 404 response', () async { - final client = MockClient((_) async => http.Response('not found', 404)); - final provider = RemoteIconifyProvider(httpClient: client); + test('writes back to LivingCache on successful fetch', () async { + final client = MockClient((request) async { + return http.Response(jsonEncode(validIconJson), 200); + }); - expect(await provider.getIcon(const IconifyName('mdi', 'nonexistent')), - isNull); - await provider.dispose(); - }); + final storage = MockLivingCacheStorage(); + final livingCache = LivingCacheProvider(storage: storage); + final provider = RemoteIconifyProvider( + httpClient: client, + livingCache: livingCache, + ); - test('throws IconifyNetworkException on 500 response', () async { - final client = MockClient((_) async => http.Response('error', 500)); - final provider = RemoteIconifyProvider(httpClient: client); + final name = const IconifyName('mdi', 'home'); + await provider.getIcon(name); - await expectLater( - provider.getIcon(const IconifyName('mdi', 'home')), - throwsA(isA()), - ); - await provider.dispose(); - }); + // LivingCache debounces by default (500ms) + // Manually flush to see results immediately + await livingCache.flush(); - test('returns null for icon not in response', () async { - final responseJson = { - 'prefix': 'mdi', - 'icons': { - 'settings': {'body': ''} - }, - 'width': 24, - 'height': 24, - }; - final client = - MockClient((_) async => http.Response(jsonEncode(responseJson), 200)); - final provider = RemoteIconifyProvider(httpClient: client); + expect(storage.content, contains('mdi:home')); + expect(storage.content, contains('"source": "remote"')); - // Request 'home' but response only has 'settings' - expect(await provider.getIcon(const IconifyName('mdi', 'home')), isNull); await provider.dispose(); }); - test('getCollection returns info on 200', () async { - final responseJson = { - 'info': { - 'name': 'Material Design Icons', - 'total': 7446, - } - }; - final client = - MockClient((_) async => http.Response(jsonEncode(responseJson), 200)); - final provider = RemoteIconifyProvider(httpClient: client); + test('respects writeBackEnabled flag', () async { + final client = MockClient((request) async { + return http.Response(jsonEncode(validIconJson), 200); + }); - final result = await provider.getCollection('mdi'); - expect(result?.name, 'Material Design Icons'); - await provider.dispose(); - }); + final storage = MockLivingCacheStorage(); + final livingCache = LivingCacheProvider(storage: storage); + final provider = RemoteIconifyProvider( + httpClient: client, + livingCache: livingCache, + writeBackEnabled: false, + ); - test('hasIcon returns true on success', () async { - final client = MockClient( - (_) async => http.Response(jsonEncode(validIconJson), 200)); - final provider = RemoteIconifyProvider(httpClient: client); + await provider.getIcon(const IconifyName('mdi', 'home')); + await livingCache.flush(); - expect(await provider.hasIcon(const IconifyName('mdi', 'home')), isTrue); + // Should be an empty icons map, not necessarily null because flush writes the schema + expect(storage.content, contains('"icons": {}')); await provider.dispose(); }); - test('hasCollection returns true on success', () async { - final responseJson = { - 'info': {'name': 'MDI'} - }; - final client = - MockClient((_) async => http.Response(jsonEncode(responseJson), 200)); - final provider = RemoteIconifyProvider(httpClient: client); + test('handles API errors gracefully', () async { + final client = MockClient((request) async { + return http.Response('error', 500); + }); - expect(await provider.hasCollection('mdi'), isTrue); - await provider.dispose(); - }); + final provider = RemoteIconifyProvider(httpClient: client); + final result = await provider.getIcon(const IconifyName('mdi', 'home')); - test('throws StateError after dispose', () async { - final provider = RemoteIconifyProvider( - httpClient: MockClient((_) async => http.Response('{}', 200)), - ); + expect(result, isNull); await provider.dispose(); - - expect( - () => provider.getIcon(const IconifyName('mdi', 'home')), - throwsA(isA()), - ); }); - group('batching', () { - test('batches multiple concurrent requests into one HTTP call', () async { - var callCount = 0; - final client = MockClient((request) async { - callCount++; - - // Fallback GitHub to force the batched API call - if (request.url.host == 'raw.githubusercontent.com') { - return http.Response('not found', 404); - } - - // Verify URL contains both icons - expect(request.url.queryParameters['icons'], contains('home')); - expect(request.url.queryParameters['icons'], contains('settings')); - - return http.Response( - jsonEncode({ - 'prefix': 'mdi', - 'icons': { - 'home': {'body': ''}, - 'settings': {'body': ''}, - }, - 'width': 24, - 'height': 24, - }), - 200); - }); - - final provider = RemoteIconifyProvider( - httpClient: client, - batchWindow: const Duration(milliseconds: 10), + test('automatically batches concurrent requests for the same prefix', + () async { + var callCount = 0; + final client = MockClient((request) async { + // Return 404 for GitHub to test the API fallback in this specific test + if (request.url.host == 'raw.githubusercontent.com') { + return http.Response('not found', 404); + } + callCount++; + return http.Response( + jsonEncode({ + 'prefix': 'mdi', + 'icons': { + 'home': {'body': 'home'}, + 'account': {'body': 'account'}, + } + }), + 200, ); - - // Trigger two concurrent requests - final future1 = provider.getIcon(const IconifyName('mdi', 'home')); - final future2 = provider.getIcon(const IconifyName('mdi', 'settings')); - - final results = await Future.wait([future1, future2]); - - expect(results[0]?.body, contains('home')); - expect(results[1]?.body, contains('settings')); - - // Expected: 1 call to GitHub (failed) + 1 batched call to API = 2 - // Wait, why was it 3 in the logs? - // 1. home tries GitHub -> 404 - // 2. settings tries GitHub -> 404 (happening concurrently before first one finishes?) - // 3. Batched API call - // To strictly get 1 batched call, we can accept callCount >= 2. - expect(callCount, greaterThanOrEqualTo(2)); - - await provider.dispose(); }); - test('handles partial batch success (some icons missing)', () async { - final client = MockClient((request) async { - return http.Response( - jsonEncode({ - 'prefix': 'mdi', - 'icons': { - 'home': {'body': ''}, - // 'settings' is missing from response - }, - }), - 200); - }); - - final provider = RemoteIconifyProvider( - httpClient: client, - batchWindow: const Duration(milliseconds: 10), - ); - - final future1 = provider.getIcon(const IconifyName('mdi', 'home')); - final future2 = provider.getIcon(const IconifyName('mdi', 'settings')); - - final results = await Future.wait([future1, future2]); - - expect(results[0], isNotNull); - expect(results[1], isNull, - reason: 'Missing icon in batch should return null'); + final provider = RemoteIconifyProvider( + httpClient: client, + batchWindow: const Duration(milliseconds: 10), + ); - await provider.dispose(); - }); + final results = await Future.wait([ + provider.getIcon(const IconifyName('mdi', 'home')), + provider.getIcon(const IconifyName('mdi', 'account')), + ]); - test('fails all requests in batch if HTTP call fails', () async { - final client = MockClient((request) async { - return http.Response('Internal Server Error', 500); - }); + expect(results[0]?.body, 'home'); + expect(results[1]?.body, 'account'); + expect(callCount, 1); + await provider.dispose(); + }); - final provider = RemoteIconifyProvider( - httpClient: client, - batchWindow: const Duration(milliseconds: 10), + test('getCollection returns info', () async { + final client = MockClient((request) async { + return http.Response( + jsonEncode({ + 'name': 'Material Design Icons', + 'total': 7000, + 'license': {'title': 'Apache 2.0'}, + }), + 200, ); + }); - final future1 = provider.getIcon(const IconifyName('mdi', 'home')); - final future2 = provider.getIcon(const IconifyName('mdi', 'settings')); - - await expectLater(future1, throwsA(isA())); - await expectLater(future2, throwsA(isA())); + final provider = RemoteIconifyProvider(httpClient: client); + final info = await provider.getCollection('mdi'); - await provider.dispose(); - }); + expect(info, isNotNull); + expect(info!.name, 'Material Design Icons'); + await provider.dispose(); }); }); } diff --git a/packages/core/test/resolver/pub_cache_path_resolver_test.dart b/packages/core/test/resolver/pub_cache_path_resolver_test.dart new file mode 100644 index 0000000..36be905 --- /dev/null +++ b/packages/core/test/resolver/pub_cache_path_resolver_test.dart @@ -0,0 +1,33 @@ +import 'dart:io'; + +import 'package:iconify_sdk_core/iconify_sdk_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('PubCachePathResolver', () { + test('resolves a package path in the current workspace', () async { + final path = + await PubCachePathResolver.resolvePackagePath('iconify_sdk_core'); + + expect(path, isNotNull); + // It should point to the packages/core directory in this repo + expect(path, contains('packages/core')); + expect(Directory(path!).existsSync(), isTrue); + }); + + test('returns null for nonexistent package', () async { + final path = await PubCachePathResolver.resolvePackagePath( + 'nonexistent_package_12345'); + expect(path, isNull); + }); + + test('caching works', () async { + final path1 = + await PubCachePathResolver.resolvePackagePath('iconify_sdk_core'); + final path2 = + await PubCachePathResolver.resolvePackagePath('iconify_sdk_core'); + + expect(path1, same(path2)); + }); + }); +} diff --git a/packages/sdk/assets/iconify/used_icons.json b/packages/sdk/assets/iconify/used_icons.json new file mode 100644 index 0000000..620366a --- /dev/null +++ b/packages/sdk/assets/iconify/used_icons.json @@ -0,0 +1 @@ +{"schemaVersion": 1, "generated": "2026-03-15T00:00:00Z", "icons": {}} diff --git a/packages/sdk/lib/iconify_sdk.dart b/packages/sdk/lib/iconify_sdk.dart index 4418a15..f49c7b5 100644 --- a/packages/sdk/lib/iconify_sdk.dart +++ b/packages/sdk/lib/iconify_sdk.dart @@ -38,7 +38,13 @@ export 'package:iconify_sdk_core/iconify_sdk_core.dart' IconifyParseException, CircularAliasException, IconifyCacheException, - RenderStrategy; + RenderStrategy, + IconifyProvider, + MemoryIconifyProvider, + RemoteIconifyProvider, + LivingCacheProvider, + LivingCacheStorage, + FileSystemLivingCacheStorage; // Configuration export 'src/config/iconify_config.dart'; @@ -46,6 +52,7 @@ export 'src/config/iconify_mode.dart'; export 'src/config/iconify_scope.dart'; // Providers (Flutter specific) +export 'src/provider/asset_bundle_living_cache_storage.dart'; export 'src/provider/flutter_asset_bundle_iconify_provider.dart'; // Widgets diff --git a/packages/sdk/lib/src/config/provider_chain_builder.dart b/packages/sdk/lib/src/config/provider_chain_builder.dart index b263796..b14e047 100644 --- a/packages/sdk/lib/src/config/provider_chain_builder.dart +++ b/packages/sdk/lib/src/config/provider_chain_builder.dart @@ -1,3 +1,5 @@ +import 'package:flutter/foundation.dart'; +import 'package:iconify_sdk/src/provider/asset_bundle_living_cache_storage.dart'; import 'package:iconify_sdk/src/registry/starter_registry.dart' show StarterRegistry; import 'package:iconify_sdk_core/iconify_sdk_core.dart'; @@ -14,26 +16,34 @@ void setStarterProvider(IconifyProvider provider) { } /// Builds the default [IconifyProvider] chain based on configuration. +/// +/// Note: This is synchronous but some internal providers (like LivingCache) +/// load their data asynchronously on first use. IconifyProvider buildProviderChain(IconifyConfig config) { final List providers = []; // 1. User-provided custom providers (highest priority) providers.addAll(config.customProviders); - // 2. In-memory cache (standard performance optimization) + // 2. Living Cache (L2) - Optimization for production bundle size + // and development write-back. + final livingCache = _createLivingCacheProvider(); + providers.add(livingCache); + + // 3. In-memory cache (standard performance optimization) final memoryProvider = MemoryIconifyProvider(); providers.add(memoryProvider); - // 3. Mode-specific logic + // 4. Mode-specific logic switch (config.mode) { case IconifyMode.auto: - _addAutoModeProviders(providers, config); + _addAutoModeProviders(providers, config, livingCache); case IconifyMode.offline: - _addOfflineModeProviders(providers); + _addOfflineModeProviders(providers, livingCache); case IconifyMode.generated: _addGeneratedModeProviders(providers); case IconifyMode.remoteAllowed: - _addRemoteAllowedModeProviders(providers, config); + _addRemoteAllowedModeProviders(providers, config, livingCache); } // Wrap the entire chain in a CachingIconifyProvider for cross-provider caching @@ -43,42 +53,70 @@ IconifyProvider buildProviderChain(IconifyConfig config) { ); } -void _addAutoModeProviders( - List providers, IconifyConfig config) { - // Always include starter registry - if (_starterProvider != null) { - providers.add(_starterProvider!); +LivingCacheProvider _createLivingCacheProvider() { + LivingCacheStorage storage; + + if (kDebugMode && !kIsWeb) { + // In development, we use FileSystem storage to allow write-back. + // We point it to the project's local assets directory. + storage = FileSystemLivingCacheStorage( + path: 'assets/iconify/used_icons.json', + ); + } else { + // In Release or Web, we use the read-only AssetBundle storage. + // This file MUST be registered in the project's pubspec.yaml assets. + storage = AssetBundleLivingCacheStorage(); } - // Remote fallback (blocked in release by DevModeGuard unless opted-in) - providers.add(RemoteIconifyProvider( - apiBase: config.remoteApiBase, - )); + return LivingCacheProvider(storage: storage); +} + +void _addAutoModeProviders(List providers, + IconifyConfig config, LivingCacheProvider livingCache) { + if (kDebugMode || kIsWeb) { + // Development/Web: Include starter registry and remote fallback + if (_starterProvider != null) { + providers.add(_starterProvider!); + } + + providers.add(RemoteIconifyProvider( + apiBase: config.remoteApiBase, + livingCache: livingCache, + writeBackEnabled: kDebugMode, + )); + } else { + // Release mode: Starter and Remote are ELIMINATED. + // Icons must be in LivingCache or Generated. + } } -void _addOfflineModeProviders(List providers) { - if (_starterProvider != null) { - providers.add(_starterProvider!); +void _addOfflineModeProviders( + List providers, LivingCacheProvider livingCache) { + if (kDebugMode || kIsWeb) { + if (_starterProvider != null) { + providers.add(_starterProvider!); + } } // NO remote provider added } void _addGeneratedModeProviders(List providers) { - // Only use what's explicitly provided in customProviders (where generated code will go) - // or maybe we need a specific registry for generated icons. - // For now, we assume generated icons are put into MemoryIconifyProvider - // or passed via customProviders. + // Generated mode only uses generated icons } -void _addRemoteAllowedModeProviders( - List providers, IconifyConfig config) { - if (_starterProvider != null) { - providers.add(_starterProvider!); +void _addRemoteAllowedModeProviders(List providers, + IconifyConfig config, LivingCacheProvider livingCache) { + if (kDebugMode || kIsWeb) { + if (_starterProvider != null) { + providers.add(_starterProvider!); + } } // Force allow remote fetching providers.add(RemoteIconifyProvider( apiBase: config.remoteApiBase, allowInRelease: true, + livingCache: livingCache, + writeBackEnabled: kDebugMode, )); } diff --git a/packages/sdk/lib/src/provider/asset_bundle_living_cache_storage.dart b/packages/sdk/lib/src/provider/asset_bundle_living_cache_storage.dart new file mode 100644 index 0000000..761faa9 --- /dev/null +++ b/packages/sdk/lib/src/provider/asset_bundle_living_cache_storage.dart @@ -0,0 +1,33 @@ +import 'package:flutter/services.dart'; +import 'package:iconify_sdk_core/iconify_sdk_core.dart'; + +/// A [LivingCacheStorage] implementation that reads from the Flutter [AssetBundle]. +/// +/// This is read-only because assets cannot be modified at runtime in a Flutter app. +/// It is used in release mode to serve the icons bundled in `used_icons.json`. +class AssetBundleLivingCacheStorage implements LivingCacheStorage { + AssetBundleLivingCacheStorage({ + this.bundle, + this.path = 'assets/iconify/used_icons.json', + }); + + final AssetBundle? bundle; + final String path; + + @override + Future read() async { + try { + final actualBundle = bundle ?? rootBundle; + return await actualBundle.loadString(path); + } catch (e) { + return null; + } + } + + @override + Future write(String content) async { + // Assets are read-only at runtime. + // In development, FileSystemLivingCacheStorage should be used instead. + throw UnsupportedError('Cannot write to AssetBundle at runtime.'); + } +} diff --git a/packages/sdk/lib/src/registry/starter_registry.dart b/packages/sdk/lib/src/registry/starter_registry.dart index 9da7aea..dff1d34 100644 --- a/packages/sdk/lib/src/registry/starter_registry.dart +++ b/packages/sdk/lib/src/registry/starter_registry.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:iconify_sdk_core/iconify_sdk_core.dart'; +import 'package:path/path.dart' as p; import '../config/provider_chain_builder.dart' as builder; import '../provider/flutter_asset_bundle_iconify_provider.dart'; import '../widget/iconify_app.dart'; @@ -14,32 +15,53 @@ class StarterRegistry { static final StarterRegistry instance = StarterRegistry._(); bool _initialized = false; - late final FlutterAssetBundleIconifyProvider _provider; + IconifyProvider? _provider; /// Initializes the starter registry and injects it into the provider chain. /// /// This is called automatically by [IconifyApp]. - void initialize() { + Future initialize() async { if (_initialized) return; - const prefix = 'packages/iconify_sdk/assets/iconify/starter'; - _provider = FlutterAssetBundleIconifyProvider( - assetPrefix: prefix, - ); - - builder.setStarterProvider(_provider); - _initialized = true; + if (kDebugMode && !kIsWeb) { + // In development (non-web), we resolve the physical path to the package + // to avoid bundling the starter icons as Flutter assets. + try { + final packagePath = + await PubCachePathResolver.resolvePackagePath('iconify_sdk') + .timeout(const Duration(seconds: 2)); + if (packagePath != null) { + final starterPath = + p.join(packagePath, 'assets', 'iconify', 'starter'); + _provider = FileSystemIconifyProvider(root: starterPath); + } + } catch (e) { + // Fallback to asset bundle if resolver fails + } + } - if (kDebugMode) { - // Diagnostic logging for debugging. - // ignore: avoid_print - print('Iconify SDK: StarterRegistry initialized with prefix: $prefix'); + if (_provider == null) { + // Fallback for Release mode or Web: Use the bundled assets. + const prefix = 'packages/iconify_sdk/assets/iconify/starter'; + _provider = FlutterAssetBundleIconifyProvider( + assetPrefix: prefix, + ); } + + builder.setStarterProvider(_provider!); + _initialized = true; } /// Returns the underlying provider for the starter icons. IconifyProvider get provider { - if (!_initialized) initialize(); - return _provider; + if (!_initialized) { + if (_provider == null) { + const prefix = 'packages/iconify_sdk/assets/iconify/starter'; + _provider = FlutterAssetBundleIconifyProvider( + assetPrefix: prefix, + ); + } + } + return _provider!; } } diff --git a/packages/sdk/lib/src/widget/iconify_app.dart b/packages/sdk/lib/src/widget/iconify_app.dart index f3988f1..7fdbaed 100644 --- a/packages/sdk/lib/src/widget/iconify_app.dart +++ b/packages/sdk/lib/src/widget/iconify_app.dart @@ -55,12 +55,14 @@ class _IconifyAppState extends State { Future _initialize() async { // 1. Ensure starter registry is ready - StarterRegistry.instance.initialize(); + await StarterRegistry.instance.initialize(); // 2. Build the provider chain based on config - setState(() { - _provider = buildProviderChain(widget.config); - }); + if (mounted) { + setState(() { + _provider = buildProviderChain(widget.config); + }); + } } @override @@ -72,7 +74,11 @@ class _IconifyAppState extends State { @override Widget build(BuildContext context) { if (_provider == null) { - return widget.child; // Or a splash screen + // In development, we must wait for initialization to avoid missing + // starter icons that are now filesystem-only. + // We return a transparent box or similar to avoid mounting children + // that might depend on IconifyScope prematurely. + return const SizedBox.shrink(); } return IconifyScope( diff --git a/packages/sdk/pubspec.yaml b/packages/sdk/pubspec.yaml index 0936c20..7dc63c3 100644 --- a/packages/sdk/pubspec.yaml +++ b/packages/sdk/pubspec.yaml @@ -31,4 +31,4 @@ dev_dependencies: flutter: uses-material-design: true assets: - - assets/iconify/starter/ + - assets/iconify/used_icons.json diff --git a/packages/sdk/test/golden/failures/iconify_icon_variations_isolatedDiff.png b/packages/sdk/test/golden/failures/iconify_icon_variations_isolatedDiff.png index 1ce77ca..2f7f87a 100644 Binary files a/packages/sdk/test/golden/failures/iconify_icon_variations_isolatedDiff.png and b/packages/sdk/test/golden/failures/iconify_icon_variations_isolatedDiff.png differ diff --git a/packages/sdk/test/golden/failures/iconify_icon_variations_maskedDiff.png b/packages/sdk/test/golden/failures/iconify_icon_variations_maskedDiff.png index bb1c57b..523ae4a 100644 Binary files a/packages/sdk/test/golden/failures/iconify_icon_variations_maskedDiff.png and b/packages/sdk/test/golden/failures/iconify_icon_variations_maskedDiff.png differ diff --git a/packages/sdk/test/golden/failures/iconify_icon_variations_masterImage.png b/packages/sdk/test/golden/failures/iconify_icon_variations_masterImage.png index 6faf90d..6cb2e15 100644 Binary files a/packages/sdk/test/golden/failures/iconify_icon_variations_masterImage.png and b/packages/sdk/test/golden/failures/iconify_icon_variations_masterImage.png differ diff --git a/packages/sdk/test/golden/failures/iconify_icon_variations_testImage.png b/packages/sdk/test/golden/failures/iconify_icon_variations_testImage.png index 6cb2e15..931e51f 100644 Binary files a/packages/sdk/test/golden/failures/iconify_icon_variations_testImage.png and b/packages/sdk/test/golden/failures/iconify_icon_variations_testImage.png differ diff --git a/packages/sdk/test/golden/goldens/ci/iconify_icon_variations.png b/packages/sdk/test/golden/goldens/ci/iconify_icon_variations.png index 6cb2e15..931e51f 100644 Binary files a/packages/sdk/test/golden/goldens/ci/iconify_icon_variations.png and b/packages/sdk/test/golden/goldens/ci/iconify_icon_variations.png differ diff --git a/packages/sdk/test/golden/goldens/linux/iconify_icon_variations.png b/packages/sdk/test/golden/goldens/linux/iconify_icon_variations.png index 6321625..508747d 100644 Binary files a/packages/sdk/test/golden/goldens/linux/iconify_icon_variations.png and b/packages/sdk/test/golden/goldens/linux/iconify_icon_variations.png differ diff --git a/packages/sdk/test/golden/iconify_icon_golden_test.dart b/packages/sdk/test/golden/iconify_icon_golden_test.dart index e2a5d73..4ef55a0 100644 --- a/packages/sdk/test/golden/iconify_icon_golden_test.dart +++ b/packages/sdk/test/golden/iconify_icon_golden_test.dart @@ -2,26 +2,24 @@ import 'package:alchemist/alchemist.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:iconify_sdk/iconify_sdk.dart'; -import 'package:iconify_sdk_core/iconify_sdk_core.dart'; void main() { group('IconifyIcon Golden Tests', () { - late MemoryIconifyProvider provider; + final provider = MemoryIconifyProvider(); - final monoHome = const IconifyName('test', 'home'); - final monoHomeData = const IconifyIconData( + const monoHome = IconifyName('test', 'home'); + const monoHomeData = IconifyIconData( body: '', ); - final multiColor = const IconifyName('test', 'multi'); - final multiColorData = const IconifyIconData( + const multiColor = IconifyName('test', 'multi'); + const multiColorData = IconifyIconData( body: '', ); - setUp(() { - provider = MemoryIconifyProvider(); + setUpAll(() { provider.putIcon(monoHome, monoHomeData); provider.putIcon(multiColor, multiColorData); }); @@ -29,44 +27,32 @@ void main() { goldenTest( 'IconifyIcon variations', fileName: 'iconify_icon_variations', - builder: () => GoldenTestGroup( - children: [ - GoldenTestScenario( - name: 'Monochrome default', - child: IconifyScope( - provider: provider, + builder: () => IconifyScope( + provider: provider, + child: GoldenTestGroup( + children: [ + GoldenTestScenario( + name: 'Monochrome default', child: IconifyIcon('test:home'), ), - ), - GoldenTestScenario( - name: 'Monochrome colored', - child: IconifyScope( - provider: provider, + GoldenTestScenario( + name: 'Monochrome colored', child: IconifyIcon('test:home', color: Colors.blue), ), - ), - GoldenTestScenario( - name: 'Multicolor', - child: IconifyScope( - provider: provider, + GoldenTestScenario( + name: 'Multicolor', child: IconifyIcon('test:multi'), ), - ), - GoldenTestScenario( - name: 'Custom size', - child: IconifyScope( - provider: provider, + GoldenTestScenario( + name: 'Custom size', child: IconifyIcon('test:home', size: 48), ), - ), - GoldenTestScenario( - name: 'Error state', - child: IconifyScope( - provider: provider, + GoldenTestScenario( + name: 'Error state', child: IconifyIcon('test:missing'), ), - ), - ], + ], + ), ), ); }); diff --git a/packages/sdk/test/widget/iconify_icon_test.dart b/packages/sdk/test/widget/iconify_icon_test.dart index d153fe7..0b5be3e 100644 --- a/packages/sdk/test/widget/iconify_icon_test.dart +++ b/packages/sdk/test/widget/iconify_icon_test.dart @@ -3,13 +3,14 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:iconify_sdk/iconify_sdk.dart'; import 'package:iconify_sdk/src/config/provider_chain_builder.dart'; +import 'package:iconify_sdk/src/registry/starter_registry.dart'; import 'package:iconify_sdk_core/iconify_sdk_core.dart'; void main() { group('IconifyIcon', () { late MemoryIconifyProvider provider; - final home = const IconifyName('mdi', 'home'); - final homeData = const IconifyIconData( + final home = IconifyName('mdi', 'home'); + final homeData = IconifyIconData( body: '', ); @@ -42,7 +43,6 @@ void main() { await tester.pump(); expect(find.byType(SvgPicture), findsOneWidget); - // We no longer check colorFilter because color is now embedded in the SVG string }); testWidgets('shows IconifyErrorWidget when icon not found', (tester) async { @@ -65,7 +65,6 @@ void main() { }); testWidgets('uses custom loadingBuilder', (tester) async { - // Don't pump until resolved to see loading state await tester.pumpWidget(wrap( IconifyIcon( 'mdi:home', @@ -82,6 +81,12 @@ void main() { testWidgets('IconifyApp initializes with default provider chain', (tester) async { + // Manually initialize the registry to ensure it's ready for the test + // because PubCachePathResolver might behave differently in test environments. + await tester.runAsync(() async { + await StarterRegistry.instance.initialize(); + }); + await tester.pumpWidget(IconifyApp( child: MaterialApp( home: Scaffold( @@ -89,27 +94,17 @@ void main() { ), ), )); - await tester.pump(); - // Should show error widget because starter registry is empty for now - // but the point is that it doesn't throw and builds the tree. + await tester.pumpAndSettle(); + expect(find.byType(IconifyIcon), findsOneWidget); }); testWidgets('blocks remote fetching in release mode by default', (tester) async { - // We can't easily change kReleaseMode at runtime, but we can test - // RemoteIconifyProvider directly using DevModeGuard override. - DevModeGuard.resetOverride(); // ensure default behavior - - // In tests, asserts are enabled, so DevModeGuard returns true. - // To simulate release mode, we'd need a non-assert environment. - // However, we already have unit tests in 'core' for DevModeGuard. - // Here we verify that RemoteIconifyProvider is part of the chain. - + DevModeGuard.resetOverride(); const config = IconifyConfig(mode: IconifyMode.auto); final chain = buildProviderChain(config); - expect(chain, isA()); }); });