From 479012c358c1a44f90cfb3054e4ff986d5d87dc4 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 3 Oct 2026 17:26:46 -0400 Subject: [PATCH 1/4] Share map theme profiles as profile codes A custom profile's menu gets Copy profile code, which puts one line of text on the clipboard: icarus-theme: plus base64url of versioned JSON (name and three colors). Import profile code, under New profile, opens a dialog that prefills from the clipboard, previews the open map in the pasted colors, and adds the profile. Junk, cut-off codes, codes from a newer Icarus, and colors you already have each get one sentence instead of a silent failure. Custom profiles now list oldest first, so an imported or new profile lands at the end instead of wherever its random id sorts. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/providers/user_preferences_provider.dart | 5 +- lib/services/map_theme_profile_code.dart | 113 +++++++ lib/widgets/map_theme_settings_section.dart | 331 ++++++++++++++++++- test/map_theme_profile_code_flow_test.dart | 193 +++++++++++ test/map_theme_profile_code_test.dart | 107 ++++++ 5 files changed, 743 insertions(+), 6 deletions(-) create mode 100644 lib/services/map_theme_profile_code.dart create mode 100644 test/map_theme_profile_code_flow_test.dart create mode 100644 test/map_theme_profile_code_test.dart 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..17a83235 --- /dev/null +++ b/lib/services/map_theme_profile_code.dart @@ -0,0 +1,113 @@ +import 'dart:convert'; + +import 'package:icarus/providers/user_preferences_provider.dart'; + +/// 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"} +/// +/// 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}$'); + + static String encode({ + required String name, + required MapThemePalette palette, + }) { + final json = jsonEncode({ + 'v': version, + 'name': name, + ...palette.toJson(), + }); + 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(); + + final colors = [decoded['base'], decoded['detail'], decoded['highlight']]; + if (!colors.every((c) => c is String && _hexColor.hasMatch(c))) { + return const MapThemeProfileCodeIncomplete(); + } + + final rawName = decoded['name']; + final trimmedName = rawName is String ? rawName.trim() : ''; + final name = trimmedName.isEmpty + ? 'Shared profile' + : trimmedName.substring(0, trimmedName.length.clamp(0, maxNameLength)); + + return MapThemeProfileCodeValid( + name: name, + palette: MapThemePalette.fromJson(decoded), + ); + } + + 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.palette}); + + final String name; + final MapThemePalette palette; +} + +/// 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..5954f7e0 100644 --- a/lib/widgets/map_theme_settings_section.dart +++ b/lib/widgets/map_theme_settings_section.dart @@ -1,11 +1,18 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.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'; @@ -125,7 +132,9 @@ class _ThemeProfilesList extends ConsumerWidget { ), const SizedBox(height: 2), ], - _NewProfileRow( + _AddProfileRow( + icon: LucideIcons.plus, + label: "New profile", enabled: canCreate, onTap: () => showMapThemeEditorDialog( context, @@ -133,10 +142,40 @@ 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; + Settings.showToast( + message: "${added.name} added", + backgroundColor: Settings.tacticalVioletTheme.primary, + actionLabel: hasActiveStrategy ? "Use it" : null, + onActionPressed: hasActiveStrategy + ? () { + if (!context.mounted) return; + _selectProfile( + context, + ref, + profile: added, + hasOverride: + ref.read(strategyThemeProvider).overridePalette != null, + ); + } + : null, + ); + } + Future _selectProfile( BuildContext context, WidgetRef ref, { @@ -193,9 +232,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 +267,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 +352,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 +392,23 @@ class _ProfileContextMenuButtonState } } + Future _copyProfileCode() async { + await Clipboard.setData( + ClipboardData( + text: MapThemeProfileCode.encode( + name: widget.profile.name, + palette: widget.profile.palette, + ), + ), + ); + if (!mounted) return; + + Settings.showToast( + message: "Profile code copied", + backgroundColor: Settings.tacticalVioletTheme.primary, + ); + } + Future _editProfilePalette() async { await showMapThemeEditorDialog( context, @@ -549,3 +618,255 @@ 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. + final clipboard = await Clipboard.getData(Clipboard.kTextPlain); + final clipboardCode = MapThemeProfileCode.find(clipboard?.text ?? ''); + 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.palette) + .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), + SizedBox( + height: 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); + final created = + await ref.read(mapThemeProfilesProvider.notifier).createProfile( + name: code.name, + palette: code.palette, + ); + 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.palette), + 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.palette), + ], + ), + ), + ], + ), + ); + } +} 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..92531e21 --- /dev/null +++ b/test/map_theme_profile_code_flow_test.dart @@ -0,0 +1,193 @@ +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/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 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 = ''; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + switch (call.method) { + case 'Clipboard.getData': + return {'text': clipboardText}; + case 'Clipboard.setData': + 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); + }); + + Future pumpSection(WidgetTester tester) async { + tester.view.physicalSize = const Size(1200, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ToastificationWrapper( + child: ShadApp( + home: Scaffold( + body: SingleChildScrollView(child: MapThemeSettingsSection()), + ), + ), + ), + ), + ); + 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(copied.palette, 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', palette: havenDusk)}'; + 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('colors you already have cannot be added twice', (tester) async { + clipboardText = + MapThemeProfileCode.encode(name: 'Copy', palette: nightMarket); + 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); + }); +} diff --git a/test/map_theme_profile_code_test.dart b/test/map_theme_profile_code_test.dart new file mode 100644 index 00000000..aa74387d --- /dev/null +++ b/test/map_theme_profile_code_test.dart @@ -0,0 +1,107 @@ +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', palette: palette); + + 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(result.palette, palette); + }); + + test('names outside ASCII survive the trip', () { + final code = + MapThemeProfileCode.encode(name: 'Ascent 夜 ✦', palette: palette); + 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', palette: palette); + 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', palette: palette); + + 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'); + }); +} From 9ac247142a632e7cc0a4d862ed0c83adb31031e5 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 3 Oct 2026 19:44:35 -0400 Subject: [PATCH 2/4] Make profile code import robust to review findings - The dialog opens even when the clipboard can't be read. - "Use it" applies the profile after Settings closes; it reads the app's container and asks through the root navigator, not the disposed section. - A failed profile write shows an error and frees the Add button. - Names are capped by code point, so an emoji at the limit stays whole. - Codes with a version below 1 are rejected. - Long messages wrap instead of clipping in a 16px slot. - The codec deals in #RRGGBB strings and imports only dart:convert, so a server can read the format without Flutter or Hive. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/services/map_theme_profile_code.dart | 37 +++++--- lib/widgets/map_theme_settings_section.dart | 94 ++++++++++++++------- test/map_theme_profile_code_flow_test.dart | 93 ++++++++++++++++++-- test/map_theme_profile_code_test.dart | 41 +++++++-- 4 files changed, 212 insertions(+), 53 deletions(-) diff --git a/lib/services/map_theme_profile_code.dart b/lib/services/map_theme_profile_code.dart index 17a83235..40e88793 100644 --- a/lib/services/map_theme_profile_code.dart +++ b/lib/services/map_theme_profile_code.dart @@ -1,7 +1,5 @@ import 'dart:convert'; -import 'package:icarus/providers/user_preferences_provider.dart'; - /// A map theme profile as one line of text, so players can paste their map /// colors into Discord and a teammate can import them. /// @@ -10,6 +8,10 @@ import 'package:icarus/providers/user_preferences_provider.dart'; /// /// {"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 { @@ -22,14 +24,17 @@ class MapThemeProfileCode { 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 MapThemePalette palette, + required Map colors, }) { final json = jsonEncode({ 'v': version, 'name': name, - ...palette.toJson(), + 'base': colors['base'], + 'detail': colors['detail'], + 'highlight': colors['highlight'], }); final encoded = base64Url.encode(utf8.encode(json)).replaceAll('=', ''); return '$prefix$encoded'; @@ -59,21 +64,30 @@ class MapThemeProfileCode { final codeVersion = decoded['v']; if (codeVersion is! int) return const MapThemeProfileCodeInvalid(); if (codeVersion > version) return const MapThemeProfileCodeNewerVersion(); + if (codeVersion != version) return const MapThemeProfileCodeInvalid(); - final colors = [decoded['base'], decoded['detail'], decoded['highlight']]; - if (!colors.every((c) => c is String && _hexColor.hasMatch(c))) { + 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' - : trimmedName.substring(0, trimmedName.length.clamp(0, maxNameLength)); + : String.fromCharCodes(trimmedName.runes.take(maxNameLength)); return MapThemeProfileCodeValid( name: name, - palette: MapThemePalette.fromJson(decoded), + colors: { + 'base': (base as String).toUpperCase(), + 'detail': (detail as String).toUpperCase(), + 'highlight': (highlight as String).toUpperCase(), + }, ); } @@ -86,10 +100,13 @@ sealed class MapThemeProfileCodeResult { } class MapThemeProfileCodeValid extends MapThemeProfileCodeResult { - const MapThemeProfileCodeValid({required this.name, required this.palette}); + const MapThemeProfileCodeValid({required this.name, required this.colors}); final String name; - final MapThemePalette palette; + + /// `base`, `detail`, and `highlight` as `#RRGGBB`, ready for + /// `MapThemePalette.fromJson`. + final Map colors; } /// Nothing pasted yet. diff --git a/lib/widgets/map_theme_settings_section.dart b/lib/widgets/map_theme_settings_section.dart index 5954f7e0..5e93c825 100644 --- a/lib/widgets/map_theme_settings_section.dart +++ b/lib/widgets/map_theme_settings_section.dart @@ -1,6 +1,7 @@ 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'; @@ -157,21 +158,15 @@ class _ThemeProfilesList extends ConsumerWidget { 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 - ? () { - if (!context.mounted) return; - _selectProfile( - context, - ref, - profile: added, - hasOverride: - ref.read(strategyThemeProvider).overridePalette != null, - ); - } + ? () => _useImportedProfile(container, added) : null, ); } @@ -183,14 +178,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 @@ -397,7 +385,7 @@ class _ProfileContextMenuButtonState ClipboardData( text: MapThemeProfileCode.encode( name: widget.profile.name, - palette: widget.profile.palette, + colors: widget.profile.palette.toJson(), ), ), ); @@ -626,8 +614,12 @@ Future _showImportProfileCodeDialog( ) async { // A code already on the clipboard fills the field, so the usual import is // Import profile code, then Add profile. - final clipboard = await Clipboard.getData(Clipboard.kTextPlain); - final clipboardCode = MapThemeProfileCode.find(clipboard?.text ?? ''); + // 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( @@ -674,7 +666,7 @@ class _ImportProfileCodeDialogState : ref .watch(mapThemeProfilesProvider) .profiles - .where((profile) => profile.palette == code.palette) + .where((profile) => profile.palette == code.toPalette()) .firstOrNull; final (String? message, bool isError) = switch (result) { @@ -739,8 +731,8 @@ class _ImportProfileCodeDialogState }, ), const SizedBox(height: 8), - SizedBox( - height: 16, + ConstrainedBox( + constraints: const BoxConstraints(minHeight: 16), child: message == null ? null : Text( @@ -764,11 +756,15 @@ class _ImportProfileCodeDialogState Future _add(MapThemeProfileCodeValid code) async { setState(() => _adding = true); - final created = - await ref.read(mapThemeProfilesProvider.notifier).createProfile( - name: code.name, - palette: code.palette, - ); + 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); @@ -838,7 +834,7 @@ class _ProfileCodePreview extends ConsumerWidget { child: SvgPicture.asset( mapAsset, colorMapper: - MapSvgColorMapper.forPalette(code.palette), + MapSvgColorMapper.forPalette(code.toPalette()), fit: BoxFit.contain, semanticsLabel: 'Profile code preview', ), @@ -861,7 +857,7 @@ class _ProfileCodePreview extends ConsumerWidget { Expanded( child: Text(code.name, overflow: TextOverflow.ellipsis), ), - _PaletteSwatches(palette: code.palette), + _PaletteSwatches(palette: code.toPalette()), ], ), ), @@ -870,3 +866,39 @@ class _ProfileCodePreview extends ConsumerWidget { ); } } + +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; + } + 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 index 92531e21..8924687b 100644 --- a/test/map_theme_profile_code_flow_test.dart +++ b/test/map_theme_profile_code_flow_test.dart @@ -8,6 +8,7 @@ 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'; @@ -19,6 +20,7 @@ void main() { late Directory tempDir; late String clipboardText; + late bool clipboardFails; late ProviderContainer container; final nightMarket = MapThemePalette( @@ -45,10 +47,14 @@ void main() { await MapThemeProfilesProvider.bootstrap(); clipboardText = ''; + clipboardFails = 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': final arguments = call.arguments as Map; @@ -73,17 +79,29 @@ void main() { 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: const ToastificationWrapper( + child: ToastificationWrapper( child: ShadApp( home: Scaffold( - body: SingleChildScrollView(child: MapThemeSettingsSection()), + body: ValueListenableBuilder( + valueListenable: showSection, + builder: (context, show, _) => show + ? const SingleChildScrollView( + child: MapThemeSettingsSection(), + ) + : const SizedBox.shrink(), + ), ), ), ), @@ -119,14 +137,14 @@ void main() { expect(copied, isA()); copied as MapThemeProfileCodeValid; expect(copied.name, 'Night Market'); - expect(copied.palette, nightMarket); + 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', palette: havenDusk)}'; + '${MapThemeProfileCode.encode(name: 'Haven Dusk', colors: havenDusk.toJson())}'; await pumpSection(tester); await tester.tap(find.text('Import profile code')); @@ -157,9 +175,64 @@ void main() { 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 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', palette: nightMarket); + MapThemeProfileCode.encode(name: 'Copy', colors: nightMarket.toJson()); await pumpSection(tester); await tester.tap(find.text('Import profile code')); @@ -189,5 +262,15 @@ void main() { 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 index aa74387d..ed742a84 100644 --- a/test/map_theme_profile_code_test.dart +++ b/test/map_theme_profile_code_test.dart @@ -16,8 +16,8 @@ void main() { base64Url.encode(utf8.encode(jsonEncode(json))).replaceAll('=', ''); test('a code round-trips its name and colors', () { - final code = - MapThemeProfileCode.encode(name: 'Lotus Moss', palette: palette); + 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_-]+$'))); @@ -25,18 +25,19 @@ void main() { expect(result, isA()); result as MapThemeProfileCodeValid; expect(result.name, 'Lotus Moss'); - expect(result.palette, palette); + expect(MapThemePalette.fromJson(result.colors), palette); }); test('names outside ASCII survive the trip', () { - final code = - MapThemeProfileCode.encode(name: 'Ascent 夜 ✦', palette: palette); + 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', palette: palette); + 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); @@ -53,7 +54,8 @@ void main() { }); test('a code cut short reads as incomplete', () { - final code = MapThemeProfileCode.encode(name: 'Moss', palette: palette); + final code = + MapThemeProfileCode.encode(name: 'Moss', colors: palette.toJson()); expect( MapThemeProfileCode.parse(code.substring(0, 40)), @@ -104,4 +106,29 @@ void main() { })) 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(), + ); + } + }); } From 0a12bfd5ba99173bc68f4217df36e5292388e7ca Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 3 Oct 2026 21:25:31 -0400 Subject: [PATCH 3/4] Make Save as profile look disabled at the profile cap It only dropped onPressed, so at 10 custom profiles it still looked clickable. shadcn draws a button disabled only with enabled: false. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/widgets/map_theme_settings_section.dart | 1 + test/map_theme_profile_code_flow_test.dart | 30 +++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/lib/widgets/map_theme_settings_section.dart b/lib/widgets/map_theme_settings_section.dart index 5e93c825..9130c849 100644 --- a/lib/widgets/map_theme_settings_section.dart +++ b/lib/widgets/map_theme_settings_section.dart @@ -80,6 +80,7 @@ class _ThemeProfilesList extends ConsumerWidget { children: [ ShadButton.ghost( size: ShadButtonSize.sm, + enabled: canCreate, onPressed: canCreate ? () => showMapThemeEditorDialog( context, diff --git a/test/map_theme_profile_code_flow_test.dart b/test/map_theme_profile_code_flow_test.dart index 8924687b..1b831b81 100644 --- a/test/map_theme_profile_code_flow_test.dart +++ b/test/map_theme_profile_code_flow_test.dart @@ -217,6 +217,36 @@ void main() { 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; From 33c343fc2776ff67dbe34fe1655fcbb5d10f5dec Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 3 Oct 2026 22:17:24 -0400 Subject: [PATCH 4/4] Report a failed copy; don't apply a deleted profile from its toast Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/widgets/map_theme_settings_section.dart | 35 +++++++++--- test/map_theme_profile_code_flow_test.dart | 60 +++++++++++++++++++++ 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/lib/widgets/map_theme_settings_section.dart b/lib/widgets/map_theme_settings_section.dart index 9130c849..57895554 100644 --- a/lib/widgets/map_theme_settings_section.dart +++ b/lib/widgets/map_theme_settings_section.dart @@ -382,14 +382,22 @@ class _ProfileContextMenuButtonState } Future _copyProfileCode() async { - await Clipboard.setData( - ClipboardData( - text: MapThemeProfileCode.encode( - name: widget.profile.name, - colors: widget.profile.palette.toJson(), + 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( @@ -895,6 +903,19 @@ Future _useImportedProfile( 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); diff --git a/test/map_theme_profile_code_flow_test.dart b/test/map_theme_profile_code_flow_test.dart index 1b831b81..d21b28bf 100644 --- a/test/map_theme_profile_code_flow_test.dart +++ b/test/map_theme_profile_code_flow_test.dart @@ -21,6 +21,7 @@ void main() { late Directory tempDir; late String clipboardText; late bool clipboardFails; + late bool clipboardWriteFails; late ProviderContainer container; final nightMarket = MapThemePalette( @@ -48,6 +49,7 @@ void main() { clipboardText = ''; clipboardFails = false; + clipboardWriteFails = false; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(SystemChannels.platform, (call) async { switch (call.method) { @@ -57,6 +59,9 @@ void main() { } 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': @@ -217,6 +222,61 @@ void main() { 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