diff --git a/lib/providers/user_preferences_provider.dart b/lib/providers/user_preferences_provider.dart index a3542ac9..32689967 100644 --- a/lib/providers/user_preferences_provider.dart +++ b/lib/providers/user_preferences_provider.dart @@ -336,9 +336,12 @@ class MapThemeProfilesProvider extends Notifier { .toList(growable: false); final builtInProfileIds = builtInProfiles.map((profile) => profile.id).toSet(); + // Oldest first, so a new or imported profile lands at the end of the + // list. Box order follows the random profile ids. final customProfiles = allProfiles .where((profile) => !builtInProfileIds.contains(profile.id)) - .toList(growable: false); + .toList() + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); final sortedProfiles = [ ...builtInProfiles, ...customProfiles, diff --git a/lib/services/map_theme_profile_code.dart b/lib/services/map_theme_profile_code.dart new file mode 100644 index 00000000..40e88793 --- /dev/null +++ b/lib/services/map_theme_profile_code.dart @@ -0,0 +1,130 @@ +import 'dart:convert'; + +/// A map theme profile as one line of text, so players can paste their map +/// colors into Discord and a teammate can import them. +/// +/// The code is `icarus-theme:` followed by base64url (unpadded) of a +/// versioned JSON object: +/// +/// {"v":1,"name":"Lotus Moss","base":"#18221A","detail":"#7FA36B","highlight":"#E3C567"} +/// +/// Colors are `#RRGGBB` strings, the same shape `MapThemePalette.toJson` +/// writes. The codec depends on nothing but `dart:convert`, so any client or +/// server can read the format. +/// +/// Parsing finds the code anywhere in the pasted text, so a code copied +/// together with the message around it still imports. +class MapThemeProfileCode { + MapThemeProfileCode._(); + + static const String prefix = 'icarus-theme:'; + static const int version = 1; + static const int maxNameLength = 40; + + static final RegExp _codePattern = RegExp(r'icarus-theme:([A-Za-z0-9_-]+)'); + static final RegExp _hexColor = RegExp(r'^#[0-9a-fA-F]{6}$'); + + /// [colors] holds `base`, `detail`, and `highlight` as `#RRGGBB`. + static String encode({ + required String name, + required Map colors, + }) { + final json = jsonEncode({ + 'v': version, + 'name': name, + 'base': colors['base'], + 'detail': colors['detail'], + 'highlight': colors['highlight'], + }); + final encoded = base64Url.encode(utf8.encode(json)).replaceAll('=', ''); + return '$prefix$encoded'; + } + + /// The first profile code inside [text], or null when it holds none. + static String? find(String text) => _codePattern.firstMatch(text)?[0]; + + static MapThemeProfileCodeResult parse(String text) { + final match = _codePattern.firstMatch(text); + if (match == null) { + return text.trim().isEmpty + ? const MapThemeProfileCodeEmpty() + : const MapThemeProfileCodeInvalid(); + } + + final Object? decoded; + try { + decoded = jsonDecode(utf8.decode(base64Url.decode(_padded(match[1]!)))); + } on FormatException { + return const MapThemeProfileCodeIncomplete(); + } + if (decoded is! Map) { + return const MapThemeProfileCodeInvalid(); + } + + final codeVersion = decoded['v']; + if (codeVersion is! int) return const MapThemeProfileCodeInvalid(); + if (codeVersion > version) return const MapThemeProfileCodeNewerVersion(); + if (codeVersion != version) return const MapThemeProfileCodeInvalid(); + + final base = decoded['base']; + final detail = decoded['detail']; + final highlight = decoded['highlight']; + if (![base, detail, highlight] + .every((c) => c is String && _hexColor.hasMatch(c))) { + return const MapThemeProfileCodeIncomplete(); + } + + final rawName = decoded['name']; + final trimmedName = rawName is String ? rawName.trim() : ''; + // Cap by code point so an emoji at the limit is never cut in half. + final name = trimmedName.isEmpty + ? 'Shared profile' + : String.fromCharCodes(trimmedName.runes.take(maxNameLength)); + + return MapThemeProfileCodeValid( + name: name, + colors: { + 'base': (base as String).toUpperCase(), + 'detail': (detail as String).toUpperCase(), + 'highlight': (highlight as String).toUpperCase(), + }, + ); + } + + static String _padded(String value) => + value.padRight(value.length + (4 - value.length % 4) % 4, '='); +} + +sealed class MapThemeProfileCodeResult { + const MapThemeProfileCodeResult(); +} + +class MapThemeProfileCodeValid extends MapThemeProfileCodeResult { + const MapThemeProfileCodeValid({required this.name, required this.colors}); + + final String name; + + /// `base`, `detail`, and `highlight` as `#RRGGBB`, ready for + /// `MapThemePalette.fromJson`. + final Map colors; +} + +/// Nothing pasted yet. +class MapThemeProfileCodeEmpty extends MapThemeProfileCodeResult { + const MapThemeProfileCodeEmpty(); +} + +/// Text with no profile code in it. +class MapThemeProfileCodeInvalid extends MapThemeProfileCodeResult { + const MapThemeProfileCodeInvalid(); +} + +/// A code that was cut short or damaged on the way. +class MapThemeProfileCodeIncomplete extends MapThemeProfileCodeResult { + const MapThemeProfileCodeIncomplete(); +} + +/// A code written by a newer Icarus than this one. +class MapThemeProfileCodeNewerVersion extends MapThemeProfileCodeResult { + const MapThemeProfileCodeNewerVersion(); +} diff --git a/lib/widgets/map_theme_settings_section.dart b/lib/widgets/map_theme_settings_section.dart index 49fb4f60..57895554 100644 --- a/lib/widgets/map_theme_settings_section.dart +++ b/lib/widgets/map_theme_settings_section.dart @@ -1,11 +1,19 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/app_navigator.dart'; +import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/services/map_theme_profile_code.dart'; +import 'package:icarus/widgets/canonical_map_artwork.dart'; import 'package:icarus/widgets/custom_text_field.dart'; import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; import 'package:icarus/widgets/dialogs/map_theme_editor_dialog.dart'; +import 'package:icarus/widgets/dot_painter.dart'; +import 'package:icarus/widgets/map_svg_color_mapper.dart'; import 'package:icarus/widgets/settings_scope_card.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -72,6 +80,7 @@ class _ThemeProfilesList extends ConsumerWidget { children: [ ShadButton.ghost( size: ShadButtonSize.sm, + enabled: canCreate, onPressed: canCreate ? () => showMapThemeEditorDialog( context, @@ -125,7 +134,9 @@ class _ThemeProfilesList extends ConsumerWidget { ), const SizedBox(height: 2), ], - _NewProfileRow( + _AddProfileRow( + icon: LucideIcons.plus, + label: "New profile", enabled: canCreate, onTap: () => showMapThemeEditorDialog( context, @@ -133,10 +144,34 @@ class _ThemeProfilesList extends ConsumerWidget { initialPalette: ref.read(effectiveMapThemePaletteProvider), ), ), + _AddProfileRow( + icon: LucideIcons.clipboardPaste, + label: "Import profile code", + enabled: canCreate, + onTap: () => _importProfileCode(context, ref), + ), ], ); } + Future _importProfileCode(BuildContext context, WidgetRef ref) async { + final added = await _showImportProfileCodeDialog(context); + if (added == null || !context.mounted) return; + + final hasActiveStrategy = ref.read(strategyProvider).strategyName != null; + // The toast outlives this section when Settings closes or the pane + // changes, so its action reads the app's container, not this ref. + final container = ProviderScope.containerOf(context, listen: false); + Settings.showToast( + message: "${added.name} added", + backgroundColor: Settings.tacticalVioletTheme.primary, + actionLabel: hasActiveStrategy ? "Use it" : null, + onActionPressed: hasActiveStrategy + ? () => _useImportedProfile(container, added) + : null, + ); + } + Future _selectProfile( BuildContext context, WidgetRef ref, { @@ -144,14 +179,7 @@ class _ThemeProfilesList extends ConsumerWidget { required bool hasOverride, }) async { if (hasOverride) { - final confirmed = await ConfirmAlertDialog.show( - context: context, - title: "Discard custom colors?", - content: - "This strategy's custom colors will be replaced with \"${profile.name}\" and can't be brought back.", - confirmText: "Discard", - isDestructive: true, - ); + final confirmed = await _confirmDiscardCustomColors(context, profile); if (!confirmed || !context.mounted) return; } ref @@ -193,9 +221,16 @@ class _ThemeProfilesList extends ConsumerWidget { } } -class _NewProfileRow extends StatelessWidget { - const _NewProfileRow({required this.enabled, required this.onTap}); +class _AddProfileRow extends StatelessWidget { + const _AddProfileRow({ + required this.icon, + required this.label, + required this.enabled, + required this.onTap, + }); + final IconData icon; + final String label; final bool enabled; final VoidCallback onTap; @@ -221,13 +256,13 @@ class _NewProfileRow extends StatelessWidget { SizedBox( width: 22, child: Icon( - LucideIcons.plus, + icon, size: 15, color: theme.mutedForeground, ), ), Text( - "New profile", + label, style: ShadTheme.of(context).textTheme.small.copyWith( color: theme.mutedForeground, ), @@ -306,6 +341,12 @@ class _ProfileContextMenuButtonState onPressed: _editProfilePalette, child: const Text("Edit colors"), ), + if (!widget.profile.isBuiltIn) + ShadContextMenuItem( + leading: const Icon(LucideIcons.copy, size: 16), + onPressed: _copyProfileCode, + child: const Text("Copy profile code"), + ), if (!widget.profile.isBuiltIn) ShadContextMenuItem( leading: Icon( @@ -340,6 +381,31 @@ class _ProfileContextMenuButtonState } } + Future _copyProfileCode() async { + try { + await Clipboard.setData( + ClipboardData( + text: MapThemeProfileCode.encode( + name: widget.profile.name, + colors: widget.profile.palette.toJson(), + ), + ), + ); + } catch (_) { + Settings.showToast( + message: "Couldn't copy the profile code.", + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + return; + } + if (!mounted) return; + + Settings.showToast( + message: "Profile code copied", + backgroundColor: Settings.tacticalVioletTheme.primary, + ); + } + Future _editProfilePalette() async { await showMapThemeEditorDialog( context, @@ -549,3 +615,312 @@ Future _showRenameDialog({ }, ); } + +/// Turns a shared profile code into a new custom profile. Returns the +/// profile it added, or null when nothing was added. +Future _showImportProfileCodeDialog( + BuildContext context, +) async { + // A code already on the clipboard fills the field, so the usual import is + // Import profile code, then Add profile. + // Reading it can fail (a browser can deny it); the field still works. + String? clipboardCode; + try { + final clipboard = await Clipboard.getData(Clipboard.kTextPlain); + clipboardCode = MapThemeProfileCode.find(clipboard?.text ?? ''); + } catch (_) {} + if (!context.mounted) return null; + + return showShadDialog( + context: context, + builder: (_) => _ImportProfileCodeDialog(clipboardCode: clipboardCode), + ); +} + +class _ImportProfileCodeDialog extends ConsumerStatefulWidget { + const _ImportProfileCodeDialog({required this.clipboardCode}); + + final String? clipboardCode; + + @override + ConsumerState<_ImportProfileCodeDialog> createState() => + _ImportProfileCodeDialogState(); +} + +class _ImportProfileCodeDialogState + extends ConsumerState<_ImportProfileCodeDialog> { + late final TextEditingController _controller = + TextEditingController(text: widget.clipboardCode ?? ''); + bool _adding = false; + + @override + void initState() { + super.initState(); + _controller.addListener(() => setState(() {})); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + const theme = Settings.tacticalVioletTheme; + final result = MapThemeProfileCode.parse(_controller.text); + final code = result is MapThemeProfileCodeValid ? result : null; + final duplicate = code == null + ? null + : ref + .watch(mapThemeProfilesProvider) + .profiles + .where((profile) => profile.palette == code.toPalette()) + .firstOrNull; + + final (String? message, bool isError) = switch (result) { + MapThemeProfileCodeEmpty() => (null, false), + MapThemeProfileCodeInvalid() => ( + "That isn't an Icarus profile code.", + true, + ), + MapThemeProfileCodeIncomplete() => ( + "This code is incomplete. Copy the whole code and paste it again.", + true, + ), + MapThemeProfileCodeNewerVersion() => ( + "This code is from a newer version of Icarus. Update Icarus to import it.", + true, + ), + MapThemeProfileCodeValid() when duplicate != null => ( + "You already have these colors as “${duplicate.name}”.", + false, + ), + MapThemeProfileCodeValid() => ( + _controller.text == widget.clipboardCode + ? "Pasted from your clipboard." + : null, + false, + ), + }; + final canAdd = code != null && duplicate == null && !_adding; + + return ShadDialog( + title: const Text("Import profile code"), + description: const Text( + "Paste a code someone shared to add their map colors to your profiles.", + ), + actions: [ + ShadButton.secondary( + onPressed: () => Navigator.of(context).pop(), + child: const Text("Cancel"), + ), + ShadButton( + enabled: canAdd, + onPressed: canAdd ? () => _add(code) : null, + child: const Text("Add profile"), + ), + ], + child: Material( + color: Colors.transparent, + child: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + CustomTextField( + controller: _controller, + hintText: "Paste a profile code", + hasError: isError, + autofocus: widget.clipboardCode == null, + onSubmitted: (_) { + if (canAdd) _add(code); + }, + ), + const SizedBox(height: 8), + ConstrainedBox( + constraints: const BoxConstraints(minHeight: 16), + child: message == null + ? null + : Text( + message, + style: ShadTheme.of(context).textTheme.small.copyWith( + fontSize: 12, + color: isError + ? theme.destructive + : theme.mutedForeground, + ), + ), + ), + const SizedBox(height: 8), + _ProfileCodePreview(code: code), + ], + ), + ), + ), + ); + } + + Future _add(MapThemeProfileCodeValid code) async { + setState(() => _adding = true); + MapThemeProfile? created; + try { + created = await ref.read(mapThemeProfilesProvider.notifier).createProfile( + name: code.name, + palette: code.toPalette(), + ); + } catch (_) { + created = null; + } + if (!mounted) return; + if (created == null) { + setState(() => _adding = false); + Settings.showToast( + message: "Couldn't add this profile.", + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + return; + } + Navigator.of(context).pop(created); + } +} + +/// The open map drawn in the pasted colors, so the importer sees what they +/// are adding before they add it. +class _ProfileCodePreview extends ConsumerWidget { + const _ProfileCodePreview({required this.code}); + + final MapThemeProfileCodeValid? code; + + @override + Widget build(BuildContext context, WidgetRef ref) { + const theme = Settings.tacticalVioletTheme; + final mapState = ref.watch(mapProvider); + final mapAsset = + 'assets/maps/${Maps.mapNames[mapState.currentMap]}_map${mapState.isAttack ? "" : "_defense"}.svg'; + final code = this.code; + + return Container( + height: 250, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.border), + gradient: RadialGradient( + radius: 1.5, + colors: [theme.card, theme.background], + ), + ), + child: Column( + children: [ + Expanded( + child: Stack( + children: [ + const Positioned.fill( + child: Padding( + padding: EdgeInsets.all(4), + child: DotGrid(), + ), + ), + if (code == null) + Center( + child: Text( + "The colors show here once you paste a code.", + style: ShadTheme.of(context).textTheme.small.copyWith( + color: theme.mutedForeground, + ), + ), + ) + else + Positioned.fill( + child: Padding( + padding: const EdgeInsets.all(12), + child: CanonicalMapArtwork( + map: mapState.currentMap, + isAttack: mapState.isAttack, + child: SvgPicture.asset( + mapAsset, + colorMapper: + MapSvgColorMapper.forPalette(code.toPalette()), + fit: BoxFit.contain, + semanticsLabel: 'Profile code preview', + ), + ), + ), + ), + ], + ), + ), + if (code != null) + Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: theme.card.withValues(alpha: 0.92), + border: Border(top: BorderSide(color: theme.border)), + ), + child: Row( + children: [ + Expanded( + child: Text(code.name, overflow: TextOverflow.ellipsis), + ), + _PaletteSwatches(palette: code.toPalette()), + ], + ), + ), + ], + ), + ); + } +} + +Future _confirmDiscardCustomColors( + BuildContext context, + MapThemeProfile profile, +) { + return ConfirmAlertDialog.show( + context: context, + title: "Discard custom colors?", + content: + "This strategy's custom colors will be replaced with \"${profile.name}\" and can't be brought back.", + confirmText: "Discard", + isDestructive: true, + ); +} + +/// The "Use it" action on an import toast: applies [profile] to the open +/// strategy, asking first when that would discard its custom colors. +Future _useImportedProfile( + ProviderContainer container, + MapThemeProfile profile, +) async { + if (container.read(strategyProvider).strategyName == null) return; + if (container.read(strategyThemeProvider).overridePalette != null) { + final context = appNavigatorKey.currentContext; + if (context == null) return; + final confirmed = await _confirmDiscardCustomColors(context, profile); + if (!confirmed) return; + } + // The toast outlives the profile if it was deleted in the meantime; never + // point the strategy at a profile that no longer exists. + final stillThere = container + .read(mapThemeProfilesProvider) + .profiles + .any((existing) => existing.id == profile.id); + if (!stillThere) { + Settings.showToast( + message: "${profile.name} was deleted.", + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + return; + } + container + .read(strategyProvider.notifier) + .setThemeProfileForCurrentStrategy(profile.id); +} + +extension on MapThemeProfileCodeValid { + MapThemePalette toPalette() => MapThemePalette.fromJson(colors); +} diff --git a/test/map_theme_profile_code_flow_test.dart b/test/map_theme_profile_code_flow_test.dart new file mode 100644 index 00000000..d21b28bf --- /dev/null +++ b/test/map_theme_profile_code_flow_test.dart @@ -0,0 +1,366 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/services/map_theme_profile_code.dart'; +import 'package:icarus/widgets/map_theme_settings_section.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:toastification/toastification.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + late String clipboardText; + late bool clipboardFails; + late bool clipboardWriteFails; + late ProviderContainer container; + + final nightMarket = MapThemePalette( + baseColorValue: 0xFF141B2D, + detailColorValue: 0xFF6C8EBF, + highlightColorValue: 0xFFE05C9C, + ); + final havenDusk = MapThemePalette( + baseColorValue: 0xFF2A1B2E, + detailColorValue: 0xFFC78B5A, + highlightColorValue: 0xFF6FD3C4, + ); + + setUpAll(() { + registerIcarusAdapters(Hive); + CoordinateSystem(playAreaSize: const Size(1920, 1080)); + }); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('icarus-profile-code-'); + Hive.init(tempDir.path); + await Hive.openBox(HiveBoxNames.mapThemeProfilesBox); + await Hive.openBox(HiveBoxNames.appPreferencesBox); + await MapThemeProfilesProvider.bootstrap(); + + clipboardText = ''; + clipboardFails = false; + clipboardWriteFails = false; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + switch (call.method) { + case 'Clipboard.getData': + if (clipboardFails) { + throw PlatformException(code: 'denied'); + } + return {'text': clipboardText}; + case 'Clipboard.setData': + if (clipboardWriteFails) { + throw PlatformException(code: 'busy'); + } + final arguments = call.arguments as Map; + clipboardText = arguments['text'] as String? ?? ''; + case 'Clipboard.hasStrings': + return {'value': clipboardText.isNotEmpty}; + } + return null; + }); + + container = ProviderContainer(); + await container + .read(mapThemeProfilesProvider.notifier) + .createProfile(name: 'Night Market', palette: nightMarket); + }); + + tearDown(() async { + container.dispose(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + await Hive.close(); + if (await tempDir.exists()) await tempDir.delete(recursive: true); + }); + + // Flip to false to take the section down while the app (and its toasts) + // stay up, the way closing Settings does. + final showSection = ValueNotifier(true); + + Future pumpSection(WidgetTester tester) async { + tester.view.physicalSize = const Size(1200, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + showSection.value = true; + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: ToastificationWrapper( + child: ShadApp( + home: Scaffold( + body: ValueListenableBuilder( + valueListenable: showSection, + builder: (context, show, _) => show + ? const SingleChildScrollView( + child: MapThemeSettingsSection(), + ) + : const SizedBox.shrink(), + ), + ), + ), + ), + ), + ); + await tester.pump(); + } + + // Hive and the clipboard answer on the real event loop. + Future settle(WidgetTester tester) async { + for (var i = 0; i < 4; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 30))); + await tester.pump(const Duration(milliseconds: 50)); + } + } + + Future finishToasts(WidgetTester tester) async { + await tester.pump(const Duration(seconds: 4)); + await tester.pumpAndSettle(); + } + + testWidgets('Copy profile code puts the profile on the clipboard', + (tester) async { + await pumpSection(tester); + + await tester.tap(find.byIcon(LucideIcons.ellipsisVertical).last); + await settle(tester); + await tester.tap(find.text('Copy profile code')); + await settle(tester); + + final copied = MapThemeProfileCode.parse(clipboardText); + expect(copied, isA()); + copied as MapThemeProfileCodeValid; + expect(copied.name, 'Night Market'); + expect(MapThemePalette.fromJson(copied.colors), nightMarket); + expect(find.text('Profile code copied'), findsOneWidget); + await finishToasts(tester); + }); + + testWidgets('a code on the clipboard imports in two clicks', (tester) async { + clipboardText = 'try ours: ' + '${MapThemeProfileCode.encode(name: 'Haven Dusk', colors: havenDusk.toJson())}'; + await pumpSection(tester); + + await tester.tap(find.text('Import profile code')); + await settle(tester); + expect(find.text('Pasted from your clipboard.'), findsOneWidget); + expect(find.text('Haven Dusk'), findsOneWidget); + + // Start the Hive write on the real event loop so it can finish. + await tester.runAsync(() async { + await tester.tap(find.text('Add profile')); + await Future.delayed(const Duration(milliseconds: 200)); + }); + await settle(tester); + + final added = Hive.box(HiveBoxNames.mapThemeProfilesBox) + .values + .where((profile) => profile.name == 'Haven Dusk') + .single; + expect(added.palette, havenDusk); + expect(added.isBuiltIn, isFalse); + expect(find.text('Haven Dusk added'), findsOneWidget); + // The new profile lands after the ones already there. + await tester.pump(const Duration(milliseconds: 500)); + expect( + tester.getTopLeft(find.text('Haven Dusk')).dy, + greaterThan(tester.getTopLeft(find.text('Night Market')).dy), + ); + await finishToasts(tester); + }); + + testWidgets('Use it still applies the profile after Settings closes', + (tester) async { + // Applying a theme marks the strategy unsaved; this test has no + // strategy box for an autosave to write to. + await tester.runAsync(() => container + .read(appPreferencesProvider.notifier) + .setAutosaveEnabled(false)); + container.read(strategyProvider.notifier).setFromState( + const StrategyState( + strategyId: 'strategy-id', + strategyName: 'Split execute', + storageDirectory: null, + isOpen: true, + ), + ); + clipboardText = MapThemeProfileCode.encode( + name: 'Haven Dusk', colors: havenDusk.toJson()); + await pumpSection(tester); + + await tester.tap(find.text('Import profile code')); + await settle(tester); + await tester.runAsync(() async { + await tester.tap(find.text('Add profile')); + await Future.delayed(const Duration(milliseconds: 200)); + }); + await settle(tester); + + showSection.value = false; + // Let the toast finish sliding in. + await tester.pump(const Duration(milliseconds: 700)); + expect(find.byType(MapThemeSettingsSection), findsNothing); + + await tester.tap(find.text('Use it')); + await tester.pump(); + final added = container + .read(mapThemeProfilesProvider) + .profiles + .singleWhere((profile) => profile.name == 'Haven Dusk'); + expect(container.read(strategyThemeProvider).profileId, added.id); + await finishToasts(tester); + }); + + testWidgets('a clipboard that refuses the copy says so', (tester) async { + clipboardWriteFails = true; + await pumpSection(tester); + + await tester.tap(find.byIcon(LucideIcons.ellipsisVertical).last); + await settle(tester); + await tester.tap(find.text('Copy profile code')); + await settle(tester); + + expect(find.text("Couldn't copy the profile code."), findsOneWidget); + expect(find.text('Profile code copied'), findsNothing); + await finishToasts(tester); + }); + + testWidgets('Use it does nothing once the profile was deleted', + (tester) async { + await tester.runAsync(() => container + .read(appPreferencesProvider.notifier) + .setAutosaveEnabled(false)); + container.read(strategyProvider.notifier).setFromState( + const StrategyState( + strategyId: 'strategy-id', + strategyName: 'Split execute', + storageDirectory: null, + isOpen: true, + ), + ); + clipboardText = MapThemeProfileCode.encode( + name: 'Haven Dusk', colors: havenDusk.toJson()); + await pumpSection(tester); + + await tester.tap(find.text('Import profile code')); + await settle(tester); + await tester.runAsync(() async { + await tester.tap(find.text('Add profile')); + await Future.delayed(const Duration(milliseconds: 200)); + }); + await settle(tester); + + final added = container + .read(mapThemeProfilesProvider) + .profiles + .singleWhere((profile) => profile.name == 'Haven Dusk'); + await tester.runAsync(() => container + .read(mapThemeProfilesProvider.notifier) + .deleteProfile(added.id)); + await tester.pump(const Duration(milliseconds: 700)); + + await tester.tap(find.text('Use it')); + await settle(tester); + expect(container.read(strategyThemeProvider).profileId, isNull); + expect(find.text('Haven Dusk was deleted.'), findsOneWidget); + await finishToasts(tester); + }); + + testWidgets('at the custom profile cap, Save as profile looks disabled', + (tester) async { + await tester.runAsync(() => container + .read(appPreferencesProvider.notifier) + .setAutosaveEnabled(false)); + final profiles = container.read(mapThemeProfilesProvider.notifier); + for (var i = 2; i <= MapThemeProfilesProvider.customProfilesSoftCap; i++) { + await tester.runAsync(() => profiles.createProfile( + name: 'Profile $i', + palette: nightMarket.copyWith(baseColorValue: 0xFF000000 + i), + )); + } + container.read(strategyProvider.notifier).setFromState( + const StrategyState( + strategyId: 'strategy-id', + strategyName: 'Split execute', + storageDirectory: null, + isOpen: true, + ), + ); + container.read(strategyThemeProvider.notifier).setOverride(havenDusk); + await pumpSection(tester); + + final save = tester.widget(find.ancestor( + of: find.text('Save as profile'), + matching: find.byType(ShadButton), + )); + expect(save.enabled, isFalse); + }); + + testWidgets('a clipboard that cannot be read still opens the dialog', + (tester) async { + clipboardFails = true; + await pumpSection(tester); + + await tester.tap(find.text('Import profile code')); + await settle(tester); + + expect(find.text('Import profile code'), findsWidgets); + expect(find.text('The colors show here once you paste a code.'), + findsOneWidget); + }); + + testWidgets('colors you already have cannot be added twice', (tester) async { + clipboardText = + MapThemeProfileCode.encode(name: 'Copy', colors: nightMarket.toJson()); + await pumpSection(tester); + + await tester.tap(find.text('Import profile code')); + await settle(tester); + + expect( + find.text('You already have these colors as “Night Market”.'), + findsOneWidget, + ); + final addButton = tester.widget( + find.ancestor( + of: find.text('Add profile'), + matching: find.byType(ShadButton), + ), + ); + expect(addButton.enabled, isFalse); + }); + + testWidgets('pasting something else explains itself', (tester) async { + await pumpSection(tester); + + await tester.tap(find.text('Import profile code')); + await settle(tester); + expect(find.text('The colors show here once you paste a code.'), + findsOneWidget); + + await tester.enterText(find.byType(EditableText), 'hello'); + await tester.pump(); + expect(find.text("That isn't an Icarus profile code."), findsOneWidget); + + // The longest message wraps onto a second line instead of clipping. + await tester.enterText( + find.byType(EditableText), + '${MapThemeProfileCode.prefix}eyJ2IjoyfQ', + ); + await tester.pump(); + const newer = + 'This code is from a newer version of Icarus. Update Icarus to import it.'; + expect(tester.getSize(find.text(newer)).height, greaterThan(16)); + }); +} diff --git a/test/map_theme_profile_code_test.dart b/test/map_theme_profile_code_test.dart new file mode 100644 index 00000000..ed742a84 --- /dev/null +++ b/test/map_theme_profile_code_test.dart @@ -0,0 +1,134 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/services/map_theme_profile_code.dart'; + +void main() { + final palette = MapThemePalette( + baseColorValue: 0xFF18221A, + detailColorValue: 0xFF7FA36B, + highlightColorValue: 0xFFE3C567, + ); + + String rawCode(Map json) => + MapThemeProfileCode.prefix + + base64Url.encode(utf8.encode(jsonEncode(json))).replaceAll('=', ''); + + test('a code round-trips its name and colors', () { + final code = MapThemeProfileCode.encode( + name: 'Lotus Moss', colors: palette.toJson()); + + expect(code, startsWith('icarus-theme:')); + expect(code, matches(RegExp(r'^icarus-theme:[A-Za-z0-9_-]+$'))); + final result = MapThemeProfileCode.parse(code); + expect(result, isA()); + result as MapThemeProfileCodeValid; + expect(result.name, 'Lotus Moss'); + expect(MapThemePalette.fromJson(result.colors), palette); + }); + + test('names outside ASCII survive the trip', () { + final code = MapThemeProfileCode.encode( + name: 'Ascent 夜 ✦', colors: palette.toJson()); + final result = MapThemeProfileCode.parse(code) as MapThemeProfileCodeValid; + expect(result.name, 'Ascent 夜 ✦'); + }); + + test('a code inside a chat message is found and parsed', () { + final code = + MapThemeProfileCode.encode(name: 'Moss', colors: palette.toJson()); + final message = 'here is ours for lotus\n$code\nlooks cleaner'; + + expect(MapThemeProfileCode.find(message), code); + expect(MapThemeProfileCode.parse(message), isA()); + }); + + test('empty text and text without a code are told apart', () { + expect(MapThemeProfileCode.parse(' '), isA()); + expect( + MapThemeProfileCode.parse('https://tracker.gg/valorant'), + isA(), + ); + expect(MapThemeProfileCode.find('no code here'), isNull); + }); + + test('a code cut short reads as incomplete', () { + final code = + MapThemeProfileCode.encode(name: 'Moss', colors: palette.toJson()); + + expect( + MapThemeProfileCode.parse(code.substring(0, 40)), + isA(), + ); + }); + + test('a code from a newer Icarus asks for an update', () { + expect( + MapThemeProfileCode.parse(rawCode({ + 'v': 2, + 'name': 'Future', + 'base': '#000000', + 'detail': '#111111', + 'highlight': '#222222', + })), + isA(), + ); + }); + + test('a code with a missing or malformed color is incomplete', () { + expect( + MapThemeProfileCode.parse(rawCode({ + 'v': 1, + 'name': 'Broken', + 'base': '#000000', + 'detail': 'red', + })), + isA(), + ); + }); + + test('names are trimmed, capped, and never empty', () { + final long = MapThemeProfileCode.parse(rawCode({ + 'v': 1, + 'name': ' ${'x' * 60} ', + 'base': '#000000', + 'detail': '#111111', + 'highlight': '#222222', + })) as MapThemeProfileCodeValid; + expect(long.name, 'x' * MapThemeProfileCode.maxNameLength); + + final unnamed = MapThemeProfileCode.parse(rawCode({ + 'v': 1, + 'base': '#000000', + 'detail': '#111111', + 'highlight': '#222222', + })) as MapThemeProfileCodeValid; + expect(unnamed.name, 'Shared profile'); + }); + + test('the name cap never cuts an emoji in half', () { + final name = '${'x' * 39}\u{1F600}tail'; + final result = MapThemeProfileCode.parse( + MapThemeProfileCode.encode(name: name, colors: palette.toJson()), + ) as MapThemeProfileCodeValid; + + expect(result.name, '${'x' * 39}\u{1F600}'); + expect(result.name.runes.length, MapThemeProfileCode.maxNameLength); + }); + + test('versions this build does not know are not imported', () { + for (final version in [0, -1]) { + expect( + MapThemeProfileCode.parse(rawCode({ + 'v': version, + 'name': 'Old', + 'base': '#000000', + 'detail': '#111111', + 'highlight': '#222222', + })), + isA(), + ); + } + }); +}