From 4a5ff45e5711413f44886dbfa1e81fd5fe2291e5 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 3 Oct 2026 13:56:58 -0400 Subject: [PATCH 1/5] Let users dim or hide the background dots A "Background dots" slider under Map layers sets how strongly the dot grid draws behind the map, the library, and exported screenshots and videos. 100% is today's look; 0% hides the grid. The value is a new AppPreferences field (index 19) that defaults to 1.0, so preferences saved by older builds load unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/hive/hive_adapters.g.dart | 8 +- lib/hive/hive_adapters.g.yaml | 4 +- lib/providers/user_preferences_provider.dart | 15 +++ lib/screenshot/screenshot_view.dart | 8 +- lib/services/video_export/video_exporter.dart | 3 + lib/widgets/dialogs/export_video_dialog.dart | 2 + lib/widgets/dot_painter.dart | 29 +++- lib/widgets/editor_toolbar.dart | 3 + lib/widgets/hover_dot_grid.dart | 26 ++-- lib/widgets/settings_tab.dart | 23 +++- test/background_dot_opacity_test.dart | 127 ++++++++++++++++++ ...interactive_map_canonical_center_test.dart | 9 ++ test/screenshot_view_test.dart | 25 ++++ test/strategy_view_skeleton_test.dart | 12 ++ .../video_export_navigation_failure_test.dart | 1 + 15 files changed, 274 insertions(+), 21 deletions(-) create mode 100644 test/background_dot_opacity_test.dart diff --git a/lib/hive/hive_adapters.g.dart b/lib/hive/hive_adapters.g.dart index e3d6e5a66..dbc8b79cc 100644 --- a/lib/hive/hive_adapters.g.dart +++ b/lib/hive/hive_adapters.g.dart @@ -1348,13 +1348,15 @@ class AppPreferencesAdapter extends TypeAdapter { discordPresenceEnabled: fields[17] == null ? true : fields[17] as bool, videoExportStepDurationSeconds: fields[18] == null ? 3.0 : (fields[18] as num).toDouble(), + backgroundDotOpacity: + fields[19] == null ? 1.0 : (fields[19] as num).toDouble(), ); } @override void write(BinaryWriter writer, AppPreferences obj) { writer - ..writeByte(18) + ..writeByte(19) ..writeByte(0) ..write(obj.defaultThemeProfileIdForNewStrategies) ..writeByte(1) @@ -1390,7 +1392,9 @@ class AppPreferencesAdapter extends TypeAdapter { ..writeByte(17) ..write(obj.discordPresenceEnabled) ..writeByte(18) - ..write(obj.videoExportStepDurationSeconds); + ..write(obj.videoExportStepDurationSeconds) + ..writeByte(19) + ..write(obj.backgroundDotOpacity); } @override diff --git a/lib/hive/hive_adapters.g.yaml b/lib/hive/hive_adapters.g.yaml index 218c54384..6d68a4253 100644 --- a/lib/hive/hive_adapters.g.yaml +++ b/lib/hive/hive_adapters.g.yaml @@ -453,7 +453,7 @@ types: index: 4 AppPreferences: typeId: 28 - nextIndex: 19 + nextIndex: 20 fields: defaultThemeProfileIdForNewStrategies: index: 0 @@ -491,6 +491,8 @@ types: index: 17 videoExportStepDurationSeconds: index: 18 + backgroundDotOpacity: + index: 19 PlacedViewConeAgent: typeId: 29 nextIndex: 11 diff --git a/lib/providers/user_preferences_provider.dart b/lib/providers/user_preferences_provider.dart index e6baf4637..7333ccad0 100644 --- a/lib/providers/user_preferences_provider.dart +++ b/lib/providers/user_preferences_provider.dart @@ -129,6 +129,10 @@ class AppPreferences extends HiveObject { final bool discordPresenceEnabled; final double videoExportStepDurationSeconds; + /// How strongly the dot grid behind maps and the library draws, from 0 + /// (hidden) to 1 (the original look). + final double backgroundDotOpacity; + AppPreferences({ required this.defaultThemeProfileIdForNewStrategies, this.autosaveEnabled = true, @@ -148,6 +152,7 @@ class AppPreferences extends HiveObject { this.drawingThickness = Settings.defaultStrokeThickness, this.discordPresenceEnabled = true, this.videoExportStepDurationSeconds = 3.0, + this.backgroundDotOpacity = 1.0, }) : customColorValues = List.unmodifiable(customColorValues ?? const []), customShortcutBindings = Map.unmodifiable(customShortcutBindings ?? const {}); @@ -171,6 +176,7 @@ class AppPreferences extends HiveObject { double? drawingThickness, bool? discordPresenceEnabled, double? videoExportStepDurationSeconds, + double? backgroundDotOpacity, }) { return AppPreferences( defaultThemeProfileIdForNewStrategies: @@ -201,6 +207,7 @@ class AppPreferences extends HiveObject { discordPresenceEnabled ?? this.discordPresenceEnabled, videoExportStepDurationSeconds: videoExportStepDurationSeconds ?? this.videoExportStepDurationSeconds, + backgroundDotOpacity: backgroundDotOpacity ?? this.backgroundDotOpacity, ); } } @@ -656,6 +663,14 @@ class AppPreferencesNotifier extends Notifier { ); } + Future setBackgroundDotOpacity(double opacity) { + return _updatePreferences( + (current) => current.copyWith( + backgroundDotOpacity: opacity.clamp(0.0, 1.0), + ), + ); + } + Future setDrawingDefaults({ int? colorValue, double? thickness, diff --git a/lib/screenshot/screenshot_view.dart b/lib/screenshot/screenshot_view.dart index 492386576..58e7ddd9c 100644 --- a/lib/screenshot/screenshot_view.dart +++ b/lib/screenshot/screenshot_view.dart @@ -31,6 +31,7 @@ class ScreenshotView extends ConsumerWidget { required this.showSpawnBarrier, required this.showRegionNames, required this.showUltOrbs, + required this.backgroundDotOpacity, required this.agents, required this.abilities, required this.text, @@ -52,6 +53,7 @@ class ScreenshotView extends ConsumerWidget { final bool showSpawnBarrier; final bool showRegionNames; final bool showUltOrbs; + final double backgroundDotOpacity; final List agents; final List abilities; final List text; @@ -132,10 +134,10 @@ class ScreenshotView extends ConsumerWidget { width: CoordinateSystem.screenShotSize.width, child: Stack( children: [ - const Positioned.fill( + Positioned.fill( child: Padding( - padding: EdgeInsets.all(4.0), - child: DotGrid(isScreenshot: true), + padding: const EdgeInsets.all(4.0), + child: DotGrid(isScreenshot: true, opacity: backgroundDotOpacity), )), Positioned( left: mapLeft, diff --git a/lib/services/video_export/video_exporter.dart b/lib/services/video_export/video_exporter.dart index 11d04e438..b567b3899 100644 --- a/lib/services/video_export/video_exporter.dart +++ b/lib/services/video_export/video_exporter.dart @@ -45,6 +45,7 @@ class VideoExporter { required this.strategy, required this.strategyState, required this.mapState, + required this.backgroundDotOpacity, required this.geometry, this.navigation, this.requireNavigation = false, @@ -53,6 +54,7 @@ class VideoExporter { final StrategyData strategy; final StrategyState strategyState; final MapState mapState; + final double backgroundDotOpacity; final VisionGeometryMap? geometry; final NavigationGeometryMap? navigation; final bool requireNavigation; @@ -345,6 +347,7 @@ class VideoExporter { showSpawnBarrier: mapState.showSpawnBarrier, showRegionNames: mapState.showRegionNames, showUltOrbs: mapState.showUltOrbs, + backgroundDotOpacity: backgroundDotOpacity, agents: page.agentData, abilities: page.abilityData, text: page.textData, diff --git a/lib/widgets/dialogs/export_video_dialog.dart b/lib/widgets/dialogs/export_video_dialog.dart index 3bc5446a5..2b8953c26 100644 --- a/lib/widgets/dialogs/export_video_dialog.dart +++ b/lib/widgets/dialogs/export_video_dialog.dart @@ -158,6 +158,8 @@ class _ExportVideoDialogState extends ConsumerState { strategy: doc, strategyState: ref.read(strategyProvider), mapState: mapState, + backgroundDotOpacity: + ref.read(appPreferencesProvider).backgroundDotOpacity, geometry: geometry, navigation: navigation, requireNavigation: requireNavigation, diff --git a/lib/widgets/dot_painter.dart b/lib/widgets/dot_painter.dart index 80cf92df6..d037839c7 100644 --- a/lib/widgets/dot_painter.dart +++ b/lib/widgets/dot_painter.dart @@ -51,25 +51,40 @@ import 'dart:ui' show PointMode; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; -class DotGrid extends StatelessWidget { +class DotGrid extends ConsumerWidget { const DotGrid({ super.key, this.isScreenshot = false, + this.opacity, }); final bool isScreenshot; + + /// Offscreen captures pass the opacity in because their isolated provider + /// container doesn't read Hive. Everywhere else the user's setting applies. + final double? opacity; + @override - Widget build(BuildContext context) { - return CustomPaint(painter: DotPainter(isScreenshot: isScreenshot)); + Widget build(BuildContext context, WidgetRef ref) { + final double opacity = this.opacity ?? + ref.watch( + appPreferencesProvider.select((prefs) => prefs.backgroundDotOpacity), + ); + return CustomPaint( + painter: DotPainter(isScreenshot: isScreenshot, opacity: opacity), + ); } } class DotPainter extends CustomPainter { - DotPainter({required this.isScreenshot}); + DotPainter({required this.isScreenshot, required this.opacity}); final bool isScreenshot; + final double opacity; Size? _cachedSize; List _cachedPoints = const []; @@ -79,8 +94,11 @@ class DotPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { + if (opacity <= 0) return; + final paint = Paint() - ..color = Settings.tacticalVioletTheme.border.withValues(alpha: 0.7) + ..color = + Settings.tacticalVioletTheme.border.withValues(alpha: 0.7 * opacity) ..strokeWidth = dotSize ..strokeCap = StrokeCap.round; @@ -124,6 +142,7 @@ class DotPainter extends CustomPainter { @override bool shouldRepaint(DotPainter oldDelegate) { + if (oldDelegate.opacity != opacity) return true; if (oldDelegate.isScreenshot != isScreenshot) { playAreaSize = isScreenshot ? CoordinateSystem.screenShotSize diff --git a/lib/widgets/editor_toolbar.dart b/lib/widgets/editor_toolbar.dart index d45e2252d..20a99d766 100644 --- a/lib/widgets/editor_toolbar.dart +++ b/lib/widgets/editor_toolbar.dart @@ -12,6 +12,7 @@ import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/screenshot_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/screenshot/capture_geometry.dart'; import 'package:icarus/screenshot/offscreen_capture.dart'; import 'package:icarus/screenshot/persistent_offscreen_renderer.dart'; @@ -179,6 +180,8 @@ class _EditorToolbarState extends ConsumerState { showSpawnBarrier: mapState.showSpawnBarrier, showRegionNames: mapState.showRegionNames, showUltOrbs: mapState.showUltOrbs, + backgroundDotOpacity: + ref.read(appPreferencesProvider).backgroundDotOpacity, agents: activePage.agentData, abilities: activePage.abilityData, text: activePage.textData, diff --git a/lib/widgets/hover_dot_grid.dart b/lib/widgets/hover_dot_grid.dart index af08f3fa0..1332784ae 100644 --- a/lib/widgets/hover_dot_grid.dart +++ b/lib/widgets/hover_dot_grid.dart @@ -4,12 +4,15 @@ import 'dart:ui' as ui; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; /// A shader-driven replacement for [DotGrid] that reacts to the cursor: /// dots near the mouse brighten and swell while the rest of the field dims. /// Falls back to nothing while the shader is loading (a single frame). -class HoverDotGrid extends StatefulWidget { +/// Both colors scale with the user's background dot opacity. +class HoverDotGrid extends ConsumerStatefulWidget { const HoverDotGrid({ super.key, this.spacing = 9.5, @@ -39,10 +42,10 @@ class HoverDotGrid extends StatefulWidget { } @override - State createState() => _HoverDotGridState(); + ConsumerState createState() => _HoverDotGridState(); } -class _HoverDotGridState extends State +class _HoverDotGridState extends ConsumerState with SingleTickerProviderStateMixin { ui.FragmentShader? _shader; late final Ticker _ticker; @@ -158,9 +161,16 @@ class _HoverDotGridState extends State @override Widget build(BuildContext context) { final shader = _shader; - if (shader == null) { + final opacity = ref.watch( + appPreferencesProvider.select((prefs) => prefs.backgroundDotOpacity), + ); + if (shader == null || opacity <= 0) { return const SizedBox.expand(); } + final baseColor = widget.baseColor ?? + Settings.tacticalVioletTheme.border.withValues(alpha: 0.7); + final glowColor = widget.glowColor ?? + Settings.tacticalVioletTheme.foreground.withValues(alpha: 0.45); return CustomPaint( size: Size.infinite, @@ -171,10 +181,8 @@ class _HoverDotGridState extends State spacing: widget.spacing, dotRadius: widget.dotRadius, glowRadius: widget.glowRadius, - baseColor: widget.baseColor ?? - Settings.tacticalVioletTheme.border.withValues(alpha: 0.7), - glowColor: widget.glowColor ?? - Settings.tacticalVioletTheme.foreground.withValues(alpha: 0.45), + baseColor: baseColor.withValues(alpha: baseColor.a * opacity), + glowColor: glowColor.withValues(alpha: glowColor.a * opacity), ), ); } @@ -237,4 +245,4 @@ class _HoverDotGridPainter extends CustomPainter { oldDelegate.baseColor != baseColor || oldDelegate.glowColor != glowColor; } -} \ No newline at end of file +} diff --git a/lib/widgets/settings_tab.dart b/lib/widgets/settings_tab.dart index 46f7e1988..54e871bb2 100644 --- a/lib/widgets/settings_tab.dart +++ b/lib/widgets/settings_tab.dart @@ -432,6 +432,24 @@ class _GlobalSettingsSections extends ConsumerWidget { ref.read(mapProvider.notifier).updateUltOrbs(value); }, ), + _SettingsSliderTile( + icon: LucideIcons.grip, + iconColor: Settings.settingsMapAccent, + title: "Background dots", + description: + "How strongly the dot grid shows behind the map and your library. 0% hides it.", + value: appPreferences.backgroundDotOpacity, + min: 0, + max: 1, + divisions: 20, + accentColor: Settings.accentInk, + valueLabel: (value) => "${(value * 100).round()}%", + onChanged: (value) { + ref + .read(appPreferencesProvider.notifier) + .setBackgroundDotOpacity(value); + }, + ), ], ), ), @@ -1229,6 +1247,7 @@ class _SettingsSliderTile extends StatefulWidget { required this.accentColor, required this.onChanged, this.onChangeCommitted, + this.valueLabel, }); final IconData icon; @@ -1242,6 +1261,7 @@ class _SettingsSliderTile extends StatefulWidget { final Color accentColor; final ValueChanged onChanged; final void Function(double start, double end)? onChangeCommitted; + final String Function(double value)? valueLabel; @override State<_SettingsSliderTile> createState() => _SettingsSliderTileState(); @@ -1289,7 +1309,8 @@ class _SettingsSliderTileState extends State<_SettingsSliderTile> { ), const SizedBox(width: 12), Text( - widget.value.toStringAsFixed(0), + widget.valueLabel?.call(widget.value) ?? + widget.value.toStringAsFixed(0), style: ShadTheme.of(context).textTheme.small.copyWith( color: Settings.tacticalVioletTheme.foreground, fontFeatures: const [FontFeature.tabularFigures()], diff --git a/test/background_dot_opacity_test.dart b/test/background_dot_opacity_test.dart new file mode 100644 index 000000000..4ed621372 --- /dev/null +++ b/test/background_dot_opacity_test.dart @@ -0,0 +1,127 @@ +import 'dart:io'; + +import 'package:flutter/material.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_adapters.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/widgets/dot_painter.dart'; + +/// Writes AppPreferences the way builds before the dot setting did: only the +/// fields they knew about, so field 19 is absent on disk. +class _PreDotOpacityPreferencesAdapter extends TypeAdapter { + @override + final typeId = 28; + + @override + AppPreferences read(BinaryReader reader) => throw UnimplementedError(); + + @override + void write(BinaryWriter writer, AppPreferences obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.defaultThemeProfileIdForNewStrategies) + ..writeByte(1) + ..write(obj.autosaveEnabled); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() { + registerIcarusAdapters(Hive); + CoordinateSystem(playAreaSize: const Size(1920, 1080)); + }); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('icarus-dot-opacity-'); + Hive.init(tempDir.path); + await Hive.openBox(HiveBoxNames.appPreferencesBox); + }); + + tearDown(() async { + await Hive.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('preferences saved before the setting existed keep full dots', () async { + await Hive.close(); + Hive.registerAdapter(_PreDotOpacityPreferencesAdapter(), override: true); + var box = + await Hive.openBox(HiveBoxNames.appPreferencesBox); + await box.put( + MapThemeProfilesProvider.appPreferencesSingletonKey, + AppPreferences( + defaultThemeProfileIdForNewStrategies: 'profile-from-old-build', + autosaveEnabled: false, + ), + ); + await Hive.close(); + + Hive.registerAdapter(AppPreferencesAdapter(), override: true); + box = await Hive.openBox(HiveBoxNames.appPreferencesBox); + final restored = + box.get(MapThemeProfilesProvider.appPreferencesSingletonKey)!; + + expect(restored.defaultThemeProfileIdForNewStrategies, + 'profile-from-old-build'); + expect(restored.autosaveEnabled, isFalse); + expect(restored.backgroundDotOpacity, 1.0); + }); + + test('dot opacity survives a provider restart and stays in range', () async { + var container = ProviderContainer(); + final preferences = container.read(appPreferencesProvider.notifier); + + await preferences.setBackgroundDotOpacity(0.35); + await Hive.box(HiveBoxNames.appPreferencesBox).flush(); + container.dispose(); + + container = ProviderContainer(); + addTearDown(container.dispose); + expect(container.read(appPreferencesProvider).backgroundDotOpacity, 0.35); + + await container + .read(appPreferencesProvider.notifier) + .setBackgroundDotOpacity(1.4); + expect(container.read(appPreferencesProvider).backgroundDotOpacity, 1.0); + }); + + testWidgets('the dot grid follows the opacity setting', (tester) async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const Center( + child: SizedBox(width: 120, height: 80, child: DotGrid()), + ), + ), + ); + final grid = find.descendant( + of: find.byType(DotGrid), + matching: find.byType(CustomPaint), + ); + expect( + tester.renderObject(grid), + paints..something((method, _) => method == #drawPoints), + ); + + await tester.runAsync(() => container + .read(appPreferencesProvider.notifier) + .setBackgroundDotOpacity(0)); + await tester.pump(); + expect(tester.renderObject(grid), paintsNothing); + }); +} diff --git a/test/interactive_map_canonical_center_test.dart b/test/interactive_map_canonical_center_test.dart index dff90fc71..b3f9ebbd4 100644 --- a/test/interactive_map_canonical_center_test.dart +++ b/test/interactive_map_canonical_center_test.dart @@ -12,6 +12,14 @@ import 'package:icarus/providers/placement_center_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +class _FixedPreferences extends AppPreferencesNotifier { + @override + AppPreferences build() => AppPreferences( + defaultThemeProfileIdForNewStrategies: + MapThemeProfilesProvider.immutableDefaultProfileId, + ); +} + class _FixedMapProvider extends MapProvider { @override MapState build() => MapState(currentMap: MapValue.ascent, isAttack: true); @@ -54,6 +62,7 @@ void main() { final container = ProviderContainer( overrides: [ + appPreferencesProvider.overrideWith(_FixedPreferences.new), mapProvider.overrideWith(_FixedMapProvider.new), drawingProvider.overrideWith(_EmptyDrawingProvider.new), penProvider.overrideWith(_FixedPenProvider.new), diff --git a/test/screenshot_view_test.dart b/test/screenshot_view_test.dart index 7331766b9..e61116a5a 100644 --- a/test/screenshot_view_test.dart +++ b/test/screenshot_view_test.dart @@ -20,6 +20,7 @@ import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/screenshot/screenshot_view.dart'; +import 'package:icarus/widgets/dot_painter.dart'; import 'package:icarus/widgets/drawing_painter.dart'; class _NoopAgentProvider extends AgentProvider { @@ -176,6 +177,7 @@ void main() { bool showSpawnBarrier = false, bool showRegionNames = false, bool showUltOrbs = false, + double backgroundDotOpacity = 1, String? pageName, }) { final strategyState = StrategyState( @@ -217,6 +219,7 @@ void main() { showSpawnBarrier: showSpawnBarrier, showRegionNames: showRegionNames, showUltOrbs: showUltOrbs, + backgroundDotOpacity: backgroundDotOpacity, agents: const [], abilities: const [], text: const [], @@ -279,6 +282,7 @@ void main() { showSpawnBarrier: false, showRegionNames: false, showUltOrbs: false, + backgroundDotOpacity: 1, agents: const [], abilities: const [], text: const [], @@ -403,6 +407,27 @@ void main() { expect(findSemanticsLabel('Ult Orbs'), findsNothing); }); + testWidgets('background dots follow the opacity the capture was given', + (tester) async { + Finder dotLayer() => find.descendant( + of: find.byType(DotGrid), + matching: find.byType(CustomPaint), + ); + + await tester.pumpWidget(buildHarness(isAttack: true)); + await tester.pumpAndSettle(); + expect( + tester.renderObject(dotLayer()), + paints..something((method, _) => method == #drawPoints), + ); + + await tester.pumpWidget( + buildHarness(isAttack: true, backgroundDotOpacity: 0), + ); + await tester.pumpAndSettle(); + expect(tester.renderObject(dotLayer()), paintsNothing); + }); + testWidgets('defense helper overlays flip for barriers and ult orbs', (tester) async { await tester.pumpWidget( diff --git a/test/strategy_view_skeleton_test.dart b/test/strategy_view_skeleton_test.dart index 3678ace34..9886ce440 100644 --- a/test/strategy_view_skeleton_test.dart +++ b/test/strategy_view_skeleton_test.dart @@ -4,10 +4,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/widgets/strategy_view_skeleton.dart'; import 'package:icarus/widgets/window_chrome.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +class _FixedPreferences extends AppPreferencesNotifier { + @override + AppPreferences build() => AppPreferences( + defaultThemeProfileIdForNewStrategies: + MapThemeProfilesProvider.immutableDefaultProfileId, + ); +} + void main() { testWidgets('loading skeleton fits the minimum desktop window', (tester) async { @@ -20,6 +29,9 @@ void main() { await tester.pumpWidget( ProviderScope( + overrides: [ + appPreferencesProvider.overrideWith(_FixedPreferences.new), + ], child: ShadApp( themeMode: ThemeMode.dark, darkTheme: ShadThemeData( diff --git a/test/video_export_navigation_failure_test.dart b/test/video_export_navigation_failure_test.dart index 2a207800d..45149afcd 100644 --- a/test/video_export_navigation_failure_test.dart +++ b/test/video_export_navigation_failure_test.dart @@ -56,6 +56,7 @@ void main() { id: 'test', storageDirectory: null), mapState: MapState(currentMap: MapValue.split, isAttack: true), + backgroundDotOpacity: 1, geometry: null, requireNavigation: requireNavigation, ); From 402332da78910090f15e731267ad63e7b780cbff Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 3 Oct 2026 14:09:46 -0400 Subject: [PATCH 2/5] Keep a hidden library grid from animating on mouse movement Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/widgets/hover_dot_grid.dart | 2 ++ test/background_dot_opacity_test.dart | 33 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/lib/widgets/hover_dot_grid.dart b/lib/widgets/hover_dot_grid.dart index 1332784ae..5c5519e48 100644 --- a/lib/widgets/hover_dot_grid.dart +++ b/lib/widgets/hover_dot_grid.dart @@ -83,6 +83,8 @@ class _HoverDotGridState extends ConsumerState void _handleGlobalPointer(PointerEvent event) { if (event.kind != PointerDeviceKind.mouse) return; + // A hidden grid has no glow to animate. + if (ref.read(appPreferencesProvider).backgroundDotOpacity <= 0) return; if (event is PointerRemovedEvent) { _onExit(); return; diff --git a/test/background_dot_opacity_test.dart b/test/background_dot_opacity_test.dart index 4ed621372..39783e5b0 100644 --- a/test/background_dot_opacity_test.dart +++ b/test/background_dot_opacity_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -10,6 +11,7 @@ import 'package:icarus/hive/hive_adapters.dart'; import 'package:icarus/hive/hive_registration.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/widgets/dot_painter.dart'; +import 'package:icarus/widgets/hover_dot_grid.dart'; /// Writes AppPreferences the way builds before the dot setting did: only the /// fields they knew about, so field 19 is absent on disk. @@ -124,4 +126,35 @@ void main() { await tester.pump(); expect(tester.renderObject(grid), paintsNothing); }); + + testWidgets('a hidden library grid ignores the mouse', (tester) async { + Future hoverSchedulesFrames(double opacity) async { + final container = ProviderContainer(); + addTearDown(container.dispose); + await tester.runAsync(() => container + .read(appPreferencesProvider.notifier) + .setBackgroundDotOpacity(opacity)); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const Directionality( + textDirection: TextDirection.ltr, + child: SizedBox(width: 200, height: 200, child: HoverDotGrid()), + ), + ), + ); + await tester.pump(); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: const Offset(50, 50)); + await mouse.moveTo(const Offset(80, 80)); + final scheduled = tester.binding.hasScheduledFrame; + await mouse.removePointer(); + await tester.pumpWidget(const SizedBox()); + return scheduled; + } + + expect(await hoverSchedulesFrames(1), isTrue); + expect(await hoverSchedulesFrames(0), isFalse); + }); } From 9cf98bd37be0b2c2801303a6606bd8f9a7363f95 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 3 Oct 2026 16:44:56 -0400 Subject: [PATCH 3/5] Say when a Hive change needs a migration An additive field whose default reproduces the old behavior loads from old records without one, as AppPreferences fields 17-19 do. The rule now asks for a migration only when old data would not load correctly, and for a test that proves the additive case. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 174b36700..2a1cf173c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ After audits, investigations, or substantial testing runs, read [answers.md](ans Before changing view cones, map blockers, or elevation handling, read [docs/vision-model.md](docs/vision-model.md). It defines which data controls wall placement and height. ## The library is sacred -Corrupted or dropped library data is unrecoverable. In local mode nothing here changes: a change to the Hive models means source models, generated adapters, and a migration (`lib/migrations/`) so that data written by any past version loads in this one. In cloud mode the op queue holds work the user believes is saved. Every op either lands or the user is told, on screen, before they walk away. When a write path is uncertain, fail loudly without saving rather than save something wrong. +Corrupted or dropped library data is unrecoverable. In local mode nothing here changes: a change to the Hive models means source models and generated adapters, plus a migration (`lib/migrations/`) whenever data written by a past version would not load correctly in this one. A new field whose default reproduces the old behavior needs no migration; a test that reads a record written without the field proves it. In cloud mode the op queue holds work the user believes is saved. Every op either lands or the user is told, on screen, before they walk away. When a write path is uncertain, fail loudly without saving rather than save something wrong. ## Sync status is a promise The chip that says synced is the app promising the work is on the server. A conflict resolved in silence makes the app lie. So does an op dropped after retries, and so do offline edits with no badge. A user who catches the app lying once stops trusting it with their library. Every state the user's work can be in has a face on screen, and when the true state is uncertain, show uncertainty. From 01f8056e0f72482e995e7bc12bb69496a32b6356 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 3 Oct 2026 19:52:50 -0400 Subject: [PATCH 4/5] Clear the library glow while the dots are hidden Moving the mouse off a hidden grid used to leave the glow target set, so turning the dots back on showed a glow at the old pointer spot until the next move. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/widgets/hover_dot_grid.dart | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/widgets/hover_dot_grid.dart b/lib/widgets/hover_dot_grid.dart index 5c5519e48..5f27f668c 100644 --- a/lib/widgets/hover_dot_grid.dart +++ b/lib/widgets/hover_dot_grid.dart @@ -83,8 +83,13 @@ class _HoverDotGridState extends ConsumerState void _handleGlobalPointer(PointerEvent event) { if (event.kind != PointerDeviceKind.mouse) return; - // A hidden grid has no glow to animate. - if (ref.read(appPreferencesProvider).backgroundDotOpacity <= 0) return; + // A hidden grid has no glow to animate. Drop any glow at once, so + // turning the dots back on doesn't show it at a stale spot. + if (ref.read(appPreferencesProvider).backgroundDotOpacity <= 0) { + _hover = 0; + _hoverTarget = 0; + return; + } if (event is PointerRemovedEvent) { _onExit(); return; From 093d33b3422b9cb68216841b3be97adf5bcfa065 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 3 Oct 2026 20:05:53 -0400 Subject: [PATCH 5/5] Test that hidden dots come back without a stale glow Co-Authored-By: Claude Opus 5.5 (1M context) --- test/hover_dot_grid_glow_test.dart | 85 ++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 test/hover_dot_grid_glow_test.dart diff --git a/test/hover_dot_grid_glow_test.dart b/test/hover_dot_grid_glow_test.dart new file mode 100644 index 000000000..82a82d98e --- /dev/null +++ b/test/hover_dot_grid_glow_test.dart @@ -0,0 +1,85 @@ +import 'dart:io'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.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/hive_boxes.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/widgets/hover_dot_grid.dart'; + +// Its own file: HoverDotGrid caches the shader load in a static, and a load +// started inside another test's fake clock never finishes for this one. +void main() { + late Directory tempDir; + + setUpAll(() { + registerIcarusAdapters(Hive); + }); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('icarus-hover-glow-'); + Hive.init(tempDir.path); + await Hive.openBox(HiveBoxNames.appPreferencesBox); + }); + + tearDown(() async { + await Hive.close(); + if (await tempDir.exists()) await tempDir.delete(recursive: true); + }); + + testWidgets('turning hidden dots back on shows no stale glow', + (tester) async { + final container = ProviderContainer(); + addTearDown(container.dispose); + Future setOpacity(double value) => tester.runAsync(() => container + .read(appPreferencesProvider.notifier) + .setBackgroundDotOpacity(value)); + Future frames() async { + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + } + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const Directionality( + textDirection: TextDirection.ltr, + child: SizedBox(width: 200, height: 200, child: HoverDotGrid()), + ), + ), + ); + // The shader loads on the real event loop. + for (var i = 0; i < 10; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50))); + await tester.pump(); + } + double glow() { + final paint = tester.widget(find.descendant( + of: find.byType(HoverDotGrid), + matching: find.byType(CustomPaint), + )); + return (paint.painter as dynamic).hover as double; + } + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: const Offset(50, 50)); + await mouse.moveTo(const Offset(80, 80)); + await frames(); + expect(glow(), greaterThan(0.5)); + + // Hide the dots, then leave the grid while they're hidden. + await setOpacity(0); + await tester.pump(); + await mouse.moveTo(const Offset(400, 400)); + await mouse.removePointer(); + + await setOpacity(1); + await frames(); + expect(glow(), 0); + }); +}