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. diff --git a/lib/hive/hive_adapters.g.dart b/lib/hive/hive_adapters.g.dart index cb510f4ff..e9baf3214 100644 --- a/lib/hive/hive_adapters.g.dart +++ b/lib/hive/hive_adapters.g.dart @@ -1140,13 +1140,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) @@ -1182,7 +1184,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 56148fe91..7fa3e40c9 100644 --- a/lib/hive/hive_adapters.g.yaml +++ b/lib/hive/hive_adapters.g.yaml @@ -371,7 +371,7 @@ types: index: 4 AppPreferences: typeId: 28 - nextIndex: 19 + nextIndex: 20 fields: defaultThemeProfileIdForNewStrategies: index: 0 @@ -409,6 +409,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 a3542ac93..fa1da7997 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/page_screenshot.dart b/lib/screenshot/page_screenshot.dart index 8f0d7132c..620182f5a 100644 --- a/lib/screenshot/page_screenshot.dart +++ b/lib/screenshot/page_screenshot.dart @@ -76,6 +76,7 @@ Future captureEditorPage(WidgetRef ref) async { showSpawnBarrier: mapState.showSpawnBarrier, showRegionNames: mapState.showRegionNames, showUltOrbs: mapState.showUltOrbs, + backgroundDotOpacity: ref.read(appPreferencesProvider).backgroundDotOpacity, agents: page.agentData, abilities: page.abilityData, text: page.textData, diff --git a/lib/screenshot/screenshot_view.dart b/lib/screenshot/screenshot_view.dart index eb77e4272..82045b5b5 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; @@ -134,10 +136,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/archive_manifest.dart b/lib/services/archive_manifest.dart index 9f2363078..aa1da6430 100644 --- a/lib/services/archive_manifest.dart +++ b/lib/services/archive_manifest.dart @@ -209,6 +209,7 @@ class ArchiveGlobals { required this.showSpawnBarrier, required this.showUltOrbs, required this.showRegionNames, + required this.backgroundDotOpacity, required this.customColorValues, this.hasCustomColorValues = true, required this.favoriteAgents, @@ -219,6 +220,7 @@ class ArchiveGlobals { final bool? showSpawnBarrier; final bool? showUltOrbs; final bool? showRegionNames; + final double? backgroundDotOpacity; final List customColorValues; final bool hasCustomColorValues; final List favoriteAgents; @@ -233,6 +235,7 @@ class ArchiveGlobals { 'showSpawnBarrier': showSpawnBarrier, 'showUltOrbs': showUltOrbs, 'showRegionNames': showRegionNames, + 'backgroundDotOpacity': backgroundDotOpacity, 'customColorValues': customColorValues, }, 'favoriteAgents': favoriteAgents, @@ -267,6 +270,9 @@ class ArchiveGlobals { showRegionNames: appPreferencesMap == null ? null : _readNullableBool(appPreferencesMap, 'showRegionNames'), + backgroundDotOpacity: appPreferencesMap == null + ? null + : _readNullableDouble(appPreferencesMap, 'backgroundDotOpacity'), customColorValues: appPreferencesMap == null ? const [] : _readOptionalList(appPreferencesMap, 'customColorValues') @@ -442,6 +448,17 @@ bool? _readNullableBool(Map json, String key) { return _readRequiredBool(json, key); } +double? _readNullableDouble(Map json, String key) { + final value = json[key]; + if (value == null) return null; + if (value is num) return value.toDouble(); + if (value is String) { + final parsed = double.tryParse(value); + if (parsed != null) return parsed; + } + throw FormatException('Expected number or null for $key'); +} + bool _readRequiredBool(Map json, String key) { final value = json[key]; if (value is bool) { diff --git a/lib/services/video_export/video_exporter.dart b/lib/services/video_export/video_exporter.dart index 966e8607f..cc7eac701 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.imageSources = const {}, this.navigation, @@ -54,6 +55,7 @@ class VideoExporter { final StrategyData strategy; final StrategyState strategyState; final MapState mapState; + final double backgroundDotOpacity; final VisionGeometryMap? geometry; /// What each image on the exported pages paints, by image id; see @@ -305,6 +307,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/strategy/strategy_import_export.dart b/lib/strategy/strategy_import_export.dart index ae49dd8f3..e29b754e2 100644 --- a/lib/strategy/strategy_import_export.dart +++ b/lib/strategy/strategy_import_export.dart @@ -1757,6 +1757,8 @@ class StrategyImportExportService { showUltOrbs: globals.showUltOrbs ?? currentPreferences.showUltOrbs, showRegionNames: globals.showRegionNames ?? currentPreferences.showRegionNames, + backgroundDotOpacity: globals.backgroundDotOpacity?.clamp(0.0, 1.0) ?? + currentPreferences.backgroundDotOpacity, customColorValues: globals.hasCustomColorValues ? globals.customColorValues : currentPreferences.customColorValues, @@ -2262,6 +2264,7 @@ class StrategyImportExportService { showSpawnBarrier: appPreferences?.showSpawnBarrier, showUltOrbs: appPreferences?.showUltOrbs, showRegionNames: appPreferences?.showRegionNames, + backgroundDotOpacity: appPreferences?.backgroundDotOpacity, customColorValues: appPreferences?.customColorValues ?? const [], favoriteAgents: favoriteAgents, ); diff --git a/lib/widgets/dialogs/export_video_dialog.dart b/lib/widgets/dialogs/export_video_dialog.dart index 80069230a..2771cae43 100644 --- a/lib/widgets/dialogs/export_video_dialog.dart +++ b/lib/widgets/dialogs/export_video_dialog.dart @@ -201,6 +201,8 @@ class _ExportVideoDialogState extends ConsumerState { strategy: source.strategy, strategyState: ref.read(strategyProvider), mapState: mapState, + backgroundDotOpacity: + ref.read(appPreferencesProvider).backgroundDotOpacity, geometry: geometry, imageSources: images.sources, navigation: navigation, diff --git a/lib/widgets/dot_painter.dart b/lib/widgets/dot_painter.dart index 53c31415b..2ffb466cc 100644 --- a/lib/widgets/dot_painter.dart +++ b/lib/widgets/dot_painter.dart @@ -51,24 +51,39 @@ import 'dart:ui' show PointMode; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.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 []; @@ -77,8 +92,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; @@ -122,6 +140,7 @@ class DotPainter extends CustomPainter { @override bool shouldRepaint(DotPainter oldDelegate) { - return oldDelegate.isScreenshot != isScreenshot; + return oldDelegate.opacity != opacity || + oldDelegate.isScreenshot != isScreenshot; } } diff --git a/lib/widgets/hover_dot_grid.dart b/lib/widgets/hover_dot_grid.dart index af08f3fa0..5f27f668c 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; @@ -80,6 +83,13 @@ class _HoverDotGridState extends State void _handleGlobalPointer(PointerEvent event) { if (event.kind != PointerDeviceKind.mouse) 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; @@ -158,9 +168,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 +188,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 +252,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 8872590e2..fee8d7936 100644 --- a/lib/widgets/settings_tab.dart +++ b/lib/widgets/settings_tab.dart @@ -451,6 +451,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); + }, + ), ], ), ), @@ -1451,6 +1469,7 @@ class _SettingsSliderTile extends StatefulWidget { required this.accentColor, required this.onChanged, this.onChangeCommitted, + this.valueLabel, }); final IconData icon; @@ -1464,6 +1483,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(); @@ -1511,7 +1531,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..39783e5b0 --- /dev/null +++ b/test/background_dot_opacity_test.dart @@ -0,0 +1,160 @@ +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/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'; +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. +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); + }); + + 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); + }); +} 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); + }); +} diff --git a/test/interactive_map_canonical_center_test.dart b/test/interactive_map_canonical_center_test.dart index 0fceb5e62..3305656ce 100644 --- a/test/interactive_map_canonical_center_test.dart +++ b/test/interactive_map_canonical_center_test.dart @@ -13,6 +13,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); @@ -55,6 +63,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 1a66d3c2e..c22bf2049 100644 --- a/test/screenshot_view_test.dart +++ b/test/screenshot_view_test.dart @@ -21,6 +21,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 { @@ -177,6 +178,7 @@ void main() { bool showSpawnBarrier = false, bool showRegionNames = false, bool showUltOrbs = false, + double backgroundDotOpacity = 1, String? pageName, }) { const strategyState = StrategyState( @@ -218,6 +220,7 @@ void main() { showSpawnBarrier: showSpawnBarrier, showRegionNames: showRegionNames, showUltOrbs: showUltOrbs, + backgroundDotOpacity: backgroundDotOpacity, agents: const [], abilities: const [], text: const [], @@ -280,6 +283,7 @@ void main() { showSpawnBarrier: false, showRegionNames: false, showUltOrbs: false, + backgroundDotOpacity: 1, agents: const [], abilities: const [], text: const [], @@ -404,6 +408,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_folder_import_test.dart b/test/strategy_folder_import_test.dart index 75122d06b..5ea85f01d 100644 --- a/test/strategy_folder_import_test.dart +++ b/test/strategy_folder_import_test.dart @@ -413,6 +413,9 @@ void main() { await container .read(appPreferencesProvider.notifier) .setCustomColorValues(const [0xFF22C55E, 0xFF38BDF8]); + await container + .read(appPreferencesProvider.notifier) + .setBackgroundDotOpacity(0.25); await container .read(favoriteAgentsProvider.notifier) .toggleFavorite(AgentType.jett); @@ -487,6 +490,12 @@ void main() { ?.customColorValues, const [0xFF22C55E, 0xFF38BDF8], ); + expect( + Hive.box(HiveBoxNames.appPreferencesBox) + .get(MapThemeProfilesProvider.appPreferencesSingletonKey) + ?.backgroundDotOpacity, + 0.25, + ); expect( Hive.box(HiveBoxNames.favoriteAgentsBox).containsKey('jett'), isTrue, @@ -523,7 +532,9 @@ void main() { decoded['globals'] as Map); final appPreferences = Map.from( globals['appPreferences'] as Map); + // Backups from before these settings existed have neither key. appPreferences.remove('customColorValues'); + appPreferences.remove('backgroundDotOpacity'); globals['appPreferences'] = appPreferences; decoded['globals'] = globals; await manifestFile.writeAsString(jsonEncode(decoded)); @@ -540,6 +551,9 @@ void main() { await container .read(appPreferencesProvider.notifier) .setCustomColorValues(const [0xFFABCDEF, 0xFF123456]); + await container + .read(appPreferencesProvider.notifier) + .setBackgroundDotOpacity(0.6); final result = await StrategyImportExportService(container).loadFromFileDrop( @@ -554,6 +568,12 @@ void main() { ?.customColorValues, const [0xFFABCDEF, 0xFF123456], ); + expect( + Hive.box(HiveBoxNames.appPreferencesBox) + .get(MapThemeProfilesProvider.appPreferencesSingletonKey) + ?.backgroundDotOpacity, + 0.6, + ); } finally { if (await exportDirectory.exists()) { await exportDirectory.delete(recursive: true); 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 d01841432..bead67214 100644 --- a/test/video_export_navigation_failure_test.dart +++ b/test/video_export_navigation_failure_test.dart @@ -57,6 +57,7 @@ void main() { id: 'test', storageDirectory: null), mapState: MapState(currentMap: MapValue.split, isAttack: true), + backgroundDotOpacity: 1, geometry: null, requireNavigation: requireNavigation, ); diff --git a/test/video_exporter_sink_test.dart b/test/video_exporter_sink_test.dart index 6f42c06df..3ce91e108 100644 --- a/test/video_exporter_sink_test.dart +++ b/test/video_exporter_sink_test.dart @@ -145,6 +145,7 @@ VideoExporter _exporter(List pages) { isOpen: true, ), mapState: MapState(currentMap: MapValue.ascent, isAttack: true), + backgroundDotOpacity: 1, geometry: null, imageSources: {'image-1': ImageBytes(_magentaPng)}, );