From 07c458706fb0be4c03dfee35720f904673edc5d5 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:05:15 -0400 Subject: [PATCH 01/27] Pull DESIGN.md and AGENTS.md guidance from main Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 2 +- DESIGN.md | 159 +++++------------------------------------------------- 2 files changed, 14 insertions(+), 147 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7cf944c8..2a1fd5dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ local mode - the shipped behavior: signed out, library on disk, exactly what mai cloud mode - signed in, library synced to Convex. op - one queued change to cloud data. an op lands when the server accepts it. -The domain vocabulary (strategy, page, lineup, .ica file, and friends) lives in CONTEXT.md, use those words exactly. DESIGN.md defines how the app must look and how we build UI, read it before touching UI. PRODUCT.md holds who this is for and how it must feel. +The domain vocabulary (strategy, page, lineup, .ica file, and friends) lives in CONTEXT.md, use those words exactly. DESIGN.md holds the rules for how we build UI (the values themselves live in `lib/const/settings.dart`), read it before touching UI. PRODUCT.md holds who this is for and how it must feel. Here's the philosophy we work by: diff --git a/DESIGN.md b/DESIGN.md index 8f61c07f..cacd3153 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,143 +1,16 @@ ---- -name: Icarus -description: A refined tactical desktop workspace for Valorant strategy planning. -colors: - tactical-background: "#09090b" - tactical-sidebar: "#141114" - tactical-card: "#18181b" - tactical-panel: "#1b1b1b" - tactical-raised: "#27272a" - tactical-border: "#27272a" - tactical-scrollbar: "#353435" - tactical-foreground: "#fafafa" - tactical-muted: "#a1a1aa" - tactical-primary: "#7c3aed" - tactical-primary-deep: "#4c1d95" - tactical-primary-foreground: "#f9fafb" - tactical-danger: "#ef4444" - tactical-favorite: "#ff9800" - tactical-favorite-danger: "#e53935" - tactical-ally: "#3a7e5d" - tactical-ally-outline: "#69f0af6a" - tactical-enemy: "#772727" - tactical-enemy-outline: "#ff52528b" - map-base: "#271406" - map-detail: "#b27c40" - map-highlight: "#f08234" -typography: - headline: - fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" - fontSize: "20px" - fontWeight: 500 - lineHeight: 1.2 - letterSpacing: "normal" - title: - fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" - fontSize: "16px" - fontWeight: 600 - lineHeight: 1.25 - letterSpacing: "normal" - body: - fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" - fontSize: "14px" - fontWeight: 400 - lineHeight: 1.35 - letterSpacing: "normal" - label: - fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" - fontSize: "12px" - fontWeight: 600 - lineHeight: 1.2 - letterSpacing: "0.3px" - micro: - fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" - fontSize: "10px" - fontWeight: 600 - lineHeight: 1.2 - letterSpacing: "0.5px" -rounded: - xs: "3px" - sm: "4px" - md: "6px" - lg: "8px" - xl: "10px" - panel: "12px" - card: "16px" - dialog: "22px" - pill: "22px" -spacing: - xxs: "2px" - xs: "4px" - sm: "6px" - md: "8px" - lg: "10px" - xl: "12px" - section: "16px" - panel: "24px" - grid-gap: "20px" -components: - button-primary: - backgroundColor: "{colors.tactical-primary}" - textColor: "{colors.tactical-primary-foreground}" - rounded: "{rounded.lg}" - padding: "8px 14px" - button-secondary: - backgroundColor: "{colors.tactical-raised}" - textColor: "{colors.tactical-foreground}" - rounded: "{rounded.lg}" - padding: "8px 14px" - icon-button: - backgroundColor: "{colors.tactical-raised}" - textColor: "{colors.tactical-foreground}" - rounded: "{rounded.lg}" - size: "40px" - tool-button-selected: - backgroundColor: "{colors.tactical-primary}" - textColor: "{colors.tactical-primary-foreground}" - rounded: "{rounded.lg}" - size: "57.8px" - search-field: - backgroundColor: "{colors.tactical-card}" - textColor: "{colors.tactical-foreground}" - rounded: "{rounded.lg}" - height: "40px" - segmented-tabs: - backgroundColor: "{colors.tactical-raised}" - textColor: "{colors.tactical-muted}" - rounded: "{rounded.md}" - padding: "2px" - strategy-card: - backgroundColor: "{colors.tactical-card}" - textColor: "{colors.tactical-foreground}" - rounded: "{rounded.card}" - padding: "8px" - sidebar-panel: - backgroundColor: "{colors.tactical-card}" - textColor: "{colors.tactical-foreground}" - rounded: "{rounded.panel}" - width: "345px" - title-strip: - backgroundColor: "{colors.tactical-card}" - textColor: "{colors.tactical-foreground}" - height: "40px" - controlHeight: "28px" - folder-card: - backgroundColor: "{colors.tactical-card}" - textColor: "{colors.tactical-foreground}" - rounded: "{rounded.panel}" - size: "232px x 64px" ---- - # Design: Icarus Icarus is a tactical workbench: dark, dense, map-first. The canvas and the tactical objects on it stay visually dominant; everything else is hardware around the bench. Polish comes from order, not ornament. +The palette, theme, and sizing constants live in `lib/const/settings.dart`, with `Settings.tacticalVioletTheme` as the ShadColorScheme. That file is the only source of truth for values. This file holds the rules for using them. + ## Building UI -- shadcn_ui is the component library. Reach for `Shad*` widgets first — `ShadDialog`, `ShadButton`, `ShadIconButton`, `ShadInput`, `ShadSelect`, `ShadTooltip`, `ShadContextMenu*`, `ShadPopover` — before Material equivalents or custom widgets. Read theme values through `ShadTheme.of(context)`. -- `ShadDialog` does not provide a `Material` ancestor. Material-dependent children (`TextField`, `InkWell`, `LinearProgressIndicator`, `Slider`) throw "No Material widget found" inside one. Wrap the dialog's `child` in `Material(color: Colors.transparent, child: ...)` — see `lib/widgets/dialogs/export_video_dialog.dart` for the idiom. -- Color, theme, and sizing constants live in `lib/const/settings.dart` (including the `tacticalVioletTheme` ShadColorScheme). Use them; never hardcode a hex that already has a name. The frontmatter above mirrors these values for reference. -- Spacing uses the 8/10/12/16/24px steps from the frontmatter. Radii: 8px controls, 12px panels, 16px cards, 22px dialogs. +- shadcn_ui is the component library. Reach for `Shad*` widgets first (`ShadDialog`, `ShadButton`, `ShadIconButton`, `ShadInput`, `ShadSelect`, `ShadTooltip`, `ShadContextMenu*`, `ShadPopover`) before Material equivalents or custom widgets. Read theme values through `ShadTheme.of(context)`. +- `ShadDialog` does not provide a `Material` ancestor. Material-dependent children (`TextField`, `InkWell`, `LinearProgressIndicator`, `Slider`) throw "No Material widget found" inside one. Wrap the dialog's `child` in `Material(color: Colors.transparent, child: ...)`; see `lib/widgets/dialogs/export_video_dialog.dart` for the idiom. +- Never hardcode a hex that already has a name in `lib/const/settings.dart`. If a color is new, name it there first. +- Spacing steps are 8/10/12/16/24px. Radii: 8px controls, 12px panels, 16px cards, 22px dialogs. +- Type roles, all in the system sans stack: headline 20px/500, title 16px/600, body 14px/400, label 12px/600, micro 10px/600. Hierarchy comes from these five roles, not from display fonts or hero-scale type. - Transitions run 150-250ms and must communicate a state change (hover, selection, reveal, loading). No motion for its own sake. ## Window chrome @@ -145,21 +18,15 @@ Icarus is a tactical workbench: dark, dense, map-first. The canvas and the tacti - Desktop builds hide the native title bar. Each top-level screen draws its own 40px strip (`lib/widgets/window_chrome.dart`): macOS keeps its traffic lights, so the strip leaves a 78px inset on the left; Windows and Linux get app-drawn caption buttons on the right; the strip is the drag handle. Web renders the same strip with no inset and no buttons. - The library strip holds the three tabs on the left and only search, sort, New, and the account on the right. Nothing else goes in it. Inside a folder, the breadcrumb lives in the content area, not the strip. -## Named rules +## Things I would like to remain consistent -**The One Command Color Rule.** Violet marks current action, selection, focus, and primary commands — nothing else. If violet appears somewhere that isn't actionable or active, it's wrong. +**The One Command Color** Violet marks current action, selection, focus, and primary commands, and nothing else. If violet appears somewhere that isn't actionable or active, it's wrong. -**The Tactical Semantics Rule.** Ally green, enemy red, favorite amber, and the map ember hues carry game meaning. Never reuse them for unrelated UI emphasis. +**The Tactical Semantics** Ally green, enemy red, defender blue, favorite amber, and the map ember hues carry game meaning. Never reuse them for unrelated UI emphasis. -**The Tonal First Rule.** Depth comes from surface steps (background → panel → raised) and 1px zinc borders. A shadow is only allowed where it explains stacking: drag previews, floating menus, card foreground details (`0 4px 12px rgba(0,0,0,0.54)` / `0 8px 24px rgba(0,0,0,0.28)`). - -**The Native Tool Rule.** System sans stack for everything. Hierarchy comes from the five frontmatter type roles (headline/title/body/label/micro), not from display fonts or hero-scale type. +**The Tonal First** Depth comes from surface steps (background, panel, raised) and 1px zinc borders. A shadow is only allowed where it explains stacking: drag previews, floating menus, card foreground details (`0 4px 12px rgba(0,0,0,0.54)` / `0 8px 24px rgba(0,0,0,0.28)`). **Every control earns its position.** If you can't say why a control sits where it sits, it isn't done. Never fill spare space with a feature. -## Don't - -- No gradients, glow, glassmorphism, or decorative effects — the anti-reference is the generic gamer overlay. -- No marketing-page composition inside the product: no hero typography, no decorative dashboards. -- No colored side-stripe borders, gradient text, or nested cards. -- No custom affordance where a standard Shad or desktop pattern already communicates the action. +## Some general rules +These steer us in the right direction. They are not hard-set, but default to following them; if you think one should be ignored, be very loud about it and get approval from us first. From c9d95b2a872a2b8f05865570827f564725e5f281 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:15:02 -0400 Subject: [PATCH 02/27] Add isolated Hive store launch flag --- README.md | 25 +++ lib/main.dart | 32 +++- lib/services/desktop_runtime_native.dart | 7 +- lib/services/desktop_runtime_stub.dart | 5 +- lib/startup/hive_store_launch.dart | 182 ++++++++++++++++++ test/hive_store_launch_test.dart | 233 +++++++++++++++++++++++ 6 files changed, 476 insertions(+), 8 deletions(-) create mode 100644 lib/startup/hive_store_launch.dart create mode 100644 test/hive_store_launch_test.dart diff --git a/README.md b/README.md index 4691478d..c765a551 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,31 @@ This rewrites the current user's `icarus://` handler to the debug executable. After testing OAuth, launch the installed Icarus app once to restore the handler back to the installed build. +### Isolated Hive store + +Desktop builds accept an absolute `--hive-store-dir` path. It moves every +Hive box, including the library, preferences, and anonymous analytics, without +touching the normal Hive files. + +Pass the option through Flutter with one Dart entrypoint argument: + +```bash +fvm flutter run -d macos -a "--hive-store-dir=/Users/your-name/Library/Containers/xyz.icarus-strats/Data/Library/Application Support/xyz.icarus-strats-demo" +``` + +The macOS app sandbox limits this path to Icarus's container unless the user +selects another directory through a native picker. The container lives at +`~/Library/Containers/xyz.icarus-strats/Data`. + +Or pass it directly to a built executable: + +```bash +./icarus --hive-store-dir "/absolute/path/to/icarus-demo-hive" +``` + +Strategy media, debug logs, and WebView data still use the normal application +support directory. Use a different absolute Hive directory for each instance. + ## Build ```bash diff --git a/lib/main.dart b/lib/main.dart index 11ec3f71..8159fc44 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -47,6 +47,7 @@ import 'package:icarus/services/guarded_sign_out.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/strategy/strategy_migrator.dart'; import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/startup/hive_store_launch.dart'; import 'package:icarus/strategy_view.dart'; import 'package:icarus/widgets/folder_navigator.dart'; import 'package:icarus/widgets/global_shortcuts.dart'; @@ -128,22 +129,43 @@ Future main(List args) async { isReleaseMode: kReleaseMode, ); + final launch = HiveStoreLaunch.parse(args); + final PreparedHiveStore? alternateHiveStore; + if (kIsWeb) { + launch.validateForWeb(); + alternateHiveStore = null; + } else { + alternateHiveStore = await launch.prepareAlternateStore( + getDefaultHiveDirectory: getApplicationSupportDirectory, + ); + } + await registerDeepLinkProtocol('icarus'); await _initializeDeepLinkHandling(); if (kIsWeb && isIcarusShareUri(Uri.base)) { _publishDeepLink(Uri.base, source: 'web_location'); } - await ensureIcarusSingleInstance(args); + await ensureIcarusSingleInstance( + launch.fileOpenArgs, + instanceId: alternateHiveStore?.windowsSingleInstanceId ?? + HiveStoreLaunch.defaultWindowsSingleInstanceId, + ); if (kIsWeb) { // On web, Hive uses IndexedDB; no path needed. await Hive.initFlutter(); } else { - // On mobile/desktop, you can still choose an explicit directory. - final dir = await getApplicationSupportDirectory(); + final hiveDirectoryPath = alternateHiveStore?.hiveDirectoryPath ?? + (await getApplicationSupportDirectory()).path; await getTemporaryDirectory(); - await Hive.initFlutter(dir.path); + await Hive.initFlutter(hiveDirectoryPath); + if (alternateHiveStore != null) { + AppErrorReporter.reportInfo( + 'Using alternate Hive store: $hiveDirectoryPath', + source: 'main.hiveStore', + ); + } } staticDrawingCursor = await CustomMouseCursor.icon( @@ -200,7 +222,7 @@ Future main(List args) async { runApp( UncontrolledProviderScope( container: appProviderContainer, - child: MyApp(data: args), + child: MyApp(data: launch.fileOpenArgs), ), ); }, diff --git a/lib/services/desktop_runtime_native.dart b/lib/services/desktop_runtime_native.dart index 841fb5ab..c18b932a 100644 --- a/lib/services/desktop_runtime_native.dart +++ b/lib/services/desktop_runtime_native.dart @@ -7,14 +7,17 @@ import 'package:windows_single_instance/windows_single_instance.dart'; bool get isWindowsRuntime => Platform.isWindows; -Future ensureIcarusSingleInstance(List args) async { +Future ensureIcarusSingleInstance( + List args, { + required String instanceId, +}) async { if (!Platform.isWindows) { return; } await WindowsSingleInstance.ensureSingleInstance( args, - 'icarus_single_instance', + instanceId, onSecondWindow: publishSecondInstanceArgs, ); } diff --git a/lib/services/desktop_runtime_stub.dart b/lib/services/desktop_runtime_stub.dart index 09c4e3e8..ce339cac 100644 --- a/lib/services/desktop_runtime_stub.dart +++ b/lib/services/desktop_runtime_stub.dart @@ -1,5 +1,8 @@ bool get isWindowsRuntime => false; -Future ensureIcarusSingleInstance(List args) async {} +Future ensureIcarusSingleInstance( + List args, { + required String instanceId, +}) async {} Future initializeIcarusDesktopWindow(String title) async {} diff --git a/lib/startup/hive_store_launch.dart b/lib/startup/hive_store_launch.dart new file mode 100644 index 00000000..81b55c0e --- /dev/null +++ b/lib/startup/hive_store_launch.dart @@ -0,0 +1,182 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:cryptography_plus/cryptography_plus.dart'; +import 'package:path/path.dart' as path; + +final class HiveStoreLaunchException implements Exception { + const HiveStoreLaunchException(this.message, [this.cause]); + + final String message; + final Object? cause; + + @override + String toString() => message; +} + +final class HiveStoreLaunch { + const HiveStoreLaunch._({ + required this.fileOpenArgs, + required String? alternateHiveDirectory, + }) : _alternateHiveDirectory = alternateHiveDirectory; + + static const optionName = '--hive-store-dir'; + static const defaultWindowsSingleInstanceId = 'icarus_single_instance'; + + final List fileOpenArgs; + final String? _alternateHiveDirectory; + + static HiveStoreLaunch parse(List rawArgs) { + final fileOpenArgs = []; + String? alternateHiveDirectory; + var sawOption = false; + + for (var index = 0; index < rawArgs.length; index += 1) { + final argument = rawArgs[index]; + if (argument == optionName) { + if (sawOption) { + throw const HiveStoreLaunchException( + '$optionName may only be supplied once.', + ); + } + if (index + 1 >= rawArgs.length || + rawArgs[index + 1] == optionName || + rawArgs[index + 1].startsWith('$optionName=')) { + throw const HiveStoreLaunchException( + '$optionName requires an absolute directory path.', + ); + } + sawOption = true; + alternateHiveDirectory = rawArgs[++index]; + _validateOptionValue(alternateHiveDirectory); + continue; + } + + if (argument.startsWith('$optionName=')) { + if (sawOption) { + throw const HiveStoreLaunchException( + '$optionName may only be supplied once.', + ); + } + sawOption = true; + alternateHiveDirectory = argument.substring(optionName.length + 1); + _validateOptionValue(alternateHiveDirectory); + continue; + } + + fileOpenArgs.add(argument); + } + + return HiveStoreLaunch._( + fileOpenArgs: List.unmodifiable(fileOpenArgs), + alternateHiveDirectory: alternateHiveDirectory, + ); + } + + void validateForWeb() { + if (_alternateHiveDirectory == null) return; + throw const HiveStoreLaunchException( + '$optionName is only available in desktop builds.', + ); + } + + Future prepareAlternateStore({ + required Future Function() getDefaultHiveDirectory, + Future Function(Directory directory)? probeDirectory, + }) async { + final requestedPath = _alternateHiveDirectory; + if (requestedPath == null) return null; + if (!path.isAbsolute(requestedPath)) { + throw HiveStoreLaunchException( + '$optionName requires an absolute path: $requestedPath', + ); + } + + try { + final requestedDirectory = Directory(path.normalize(requestedPath)); + await requestedDirectory.create(recursive: true); + final canonicalPath = path.normalize( + await requestedDirectory.resolveSymbolicLinks(), + ); + final canonicalDirectory = Directory(canonicalPath); + await (probeDirectory ?? _probeDirectory)(canonicalDirectory); + + final defaultDirectory = await getDefaultHiveDirectory(); + final defaultPath = await _resolvedDirectoryPath(defaultDirectory); + final isDefaultDirectory = + _pathIdentity(canonicalPath) == _pathIdentity(defaultPath); + + return PreparedHiveStore( + hiveDirectoryPath: canonicalPath, + windowsSingleInstanceId: isDefaultDirectory + ? defaultWindowsSingleInstanceId + : await _windowsSingleInstanceId(canonicalPath), + ); + } on HiveStoreLaunchException { + rethrow; + } catch (error) { + throw HiveStoreLaunchException( + 'Could not prepare Hive store directory: $requestedPath', + error, + ); + } + } + + static void _validateOptionValue(String value) { + if (value.isEmpty) { + throw const HiveStoreLaunchException( + '$optionName requires a non-empty directory path.', + ); + } + if (value.contains('\u0000')) { + throw const HiveStoreLaunchException( + '$optionName cannot contain a NUL character.', + ); + } + } + + static Future _probeDirectory(Directory directory) async { + final probeDirectory = await directory.createTemp('.icarus-hive-probe-'); + try { + final probeFile = File(path.join(probeDirectory.path, 'write-probe')); + await probeFile.writeAsBytes(const [0], flush: true); + } finally { + if (await probeDirectory.exists()) { + await probeDirectory.delete(recursive: true); + } + } + } + + static Future _resolvedDirectoryPath(Directory directory) async { + final absoluteDirectory = + Directory(path.normalize(directory.absolute.path)); + if (!await absoluteDirectory.exists()) return absoluteDirectory.path; + return path.normalize(await absoluteDirectory.resolveSymbolicLinks()); + } + + static String _pathIdentity(String directoryPath) { + final normalized = path.normalize(directoryPath); + return Platform.isWindows ? normalized.toLowerCase() : normalized; + } + + static Future _windowsSingleInstanceId( + String canonicalPath, + ) async { + final digest = + await Sha256().hash(utf8.encode(_pathIdentity(canonicalPath))); + final hex = digest.bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + return '${defaultWindowsSingleInstanceId}_$hex'; + } +} + +final class PreparedHiveStore { + const PreparedHiveStore({ + required this.hiveDirectoryPath, + required this.windowsSingleInstanceId, + }); + + final String hiveDirectoryPath; + final String windowsSingleInstanceId; +} diff --git a/test/hive_store_launch_test.dart b/test/hive_store_launch_test.dart new file mode 100644 index 00000000..0e0b4217 --- /dev/null +++ b/test/hive_store_launch_test.dart @@ -0,0 +1,233 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/startup/hive_store_launch.dart'; +import 'package:path/path.dart' as path; + +void main() { + group('HiveStoreLaunch.parse', () { + test('keeps file arguments unchanged when no option is present', () { + final launch = HiveStoreLaunch.parse(['first.ica', 'second.ica']); + + expect(launch.fileOpenArgs, ['first.ica', 'second.ica']); + }); + + test('consumes the separate option and value', () { + final launch = HiveStoreLaunch.parse([ + 'first.ica', + HiveStoreLaunch.optionName, + '/tmp/icarus-demo', + 'second.ica', + ]); + + expect(launch.fileOpenArgs, ['first.ica', 'second.ica']); + }); + + test('consumes the equals form', () { + final launch = HiveStoreLaunch.parse([ + '${HiveStoreLaunch.optionName}=/tmp/icarus-demo', + 'strategy.ica', + ]); + + expect(launch.fileOpenArgs, ['strategy.ica']); + }); + + test('rejects duplicate options', () { + expect( + () => HiveStoreLaunch.parse([ + '${HiveStoreLaunch.optionName}=/tmp/one', + HiveStoreLaunch.optionName, + '/tmp/two', + ]), + throwsA(isA()), + ); + }); + + test('rejects missing, empty, and NUL values', () { + for (final arguments in [ + [HiveStoreLaunch.optionName], + ['${HiveStoreLaunch.optionName}='], + ['${HiveStoreLaunch.optionName}=/tmp/bad\u0000path'], + ]) { + expect( + () => HiveStoreLaunch.parse(arguments), + throwsA(isA()), + ); + } + }); + }); + + group('HiveStoreLaunch.prepareAlternateStore', () { + late Directory testRoot; + + setUp(() async { + testRoot = await Directory.systemTemp.createTemp('icarus-hive-launch-'); + }); + + tearDown(() async { + if (await testRoot.exists()) { + await testRoot.delete(recursive: true); + } + }); + + test('does no filesystem work without an alternate option', () async { + var requestedDefault = false; + var probedDirectory = false; + final launch = HiveStoreLaunch.parse(const []); + + final prepared = await launch.prepareAlternateStore( + getDefaultHiveDirectory: () async { + requestedDefault = true; + return testRoot; + }, + probeDirectory: (_) async { + probedDirectory = true; + }, + ); + + expect(prepared, isNull); + expect(requestedDefault, isFalse); + expect(probedDirectory, isFalse); + }); + + test('rejects a relative path before filesystem work', () async { + var requestedDefault = false; + var probedDirectory = false; + final launch = HiveStoreLaunch.parse([ + HiveStoreLaunch.optionName, + 'relative/demo-hive', + ]); + + await expectLater( + launch.prepareAlternateStore( + getDefaultHiveDirectory: () async { + requestedDefault = true; + return testRoot; + }, + probeDirectory: (_) async { + probedDirectory = true; + }, + ), + throwsA(isA()), + ); + expect(requestedDefault, isFalse); + expect(probedDirectory, isFalse); + }); + + test('creates, resolves, and probes an alternate directory', () async { + final requestedDirectory = Directory( + path.join(testRoot.path, 'nested', '..', 'demo-hive'), + ); + Directory? probedDirectory; + final launch = HiveStoreLaunch.parse([ + '${HiveStoreLaunch.optionName}=${requestedDirectory.path}', + ]); + + final prepared = await launch.prepareAlternateStore( + getDefaultHiveDirectory: () async => + Directory(path.join(testRoot.path, 'default-hive')), + probeDirectory: (directory) async { + probedDirectory = directory; + }, + ); + + final resolvedRequestedPath = path.normalize( + await Directory( + path.normalize(requestedDirectory.path), + ).resolveSymbolicLinks(), + ); + expect(prepared, isNotNull); + expect(prepared!.hiveDirectoryPath, resolvedRequestedPath); + expect(probedDirectory?.path, resolvedRequestedPath); + expect(await Directory(resolvedRequestedPath).exists(), isTrue); + expect( + prepared.windowsSingleInstanceId, + matches(RegExp(r'^icarus_single_instance_[0-9a-f]{64}$')), + ); + }); + + test('uses the legacy instance id for the default directory', () async { + final defaultDirectory = Directory(path.join(testRoot.path, 'default')); + await defaultDirectory.create(); + final launch = HiveStoreLaunch.parse([ + HiveStoreLaunch.optionName, + path.join(defaultDirectory.path, '.'), + ]); + + final prepared = await launch.prepareAlternateStore( + getDefaultHiveDirectory: () async => defaultDirectory, + probeDirectory: (_) async {}, + ); + + expect( + prepared?.windowsSingleInstanceId, + HiveStoreLaunch.defaultWindowsSingleInstanceId, + ); + }); + + test('maps a symlink alias of the default directory to the legacy id', + () async { + if (Platform.isWindows) return; + + final defaultDirectory = Directory(path.join(testRoot.path, 'default')); + await defaultDirectory.create(); + final alias = Link(path.join(testRoot.path, 'default-alias')); + await alias.create(defaultDirectory.path); + final launch = HiveStoreLaunch.parse([ + HiveStoreLaunch.optionName, + alias.path, + ]); + + final prepared = await launch.prepareAlternateStore( + getDefaultHiveDirectory: () async => defaultDirectory, + probeDirectory: (_) async {}, + ); + + expect( + prepared?.windowsSingleInstanceId, + HiveStoreLaunch.defaultWindowsSingleInstanceId, + ); + }); + + test('wraps an unusable-directory failure without falling back', () async { + final requestedDirectory = Directory(path.join(testRoot.path, 'blocked')); + final launch = HiveStoreLaunch.parse([ + HiveStoreLaunch.optionName, + requestedDirectory.path, + ]); + + await expectLater( + launch.prepareAlternateStore( + getDefaultHiveDirectory: () async => testRoot, + probeDirectory: (_) async { + throw const FileSystemException('blocked'); + }, + ), + throwsA( + isA().having( + (error) => error.cause, + 'cause', + isA(), + ), + ), + ); + }); + }); + + group('HiveStoreLaunch.validateForWeb', () { + test('allows the default IndexedDB store', () { + expect(HiveStoreLaunch.parse(const []).validateForWeb, returnsNormally); + }); + + test('rejects a filesystem override', () { + final launch = HiveStoreLaunch.parse([ + '${HiveStoreLaunch.optionName}=/tmp/icarus-demo', + ]); + + expect( + launch.validateForWeb, + throwsA(isA()), + ); + }); + }); +} From f37be8f72d988cd5389de97adbcfbc780da3b051 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 4 Sep 2026 15:01:45 -0400 Subject: [PATCH 03/27] Fix duplicate Windows instance shutdown --- lib/services/desktop_runtime_native.dart | 2 ++ lib/startup/windows_process_termination.dart | 2 ++ .../windows_process_termination_io.dart | 22 +++++++++++++++++++ .../windows_process_termination_stub.dart | 3 +++ pubspec.lock | 2 +- pubspec.yaml | 1 + 6 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 lib/startup/windows_process_termination.dart create mode 100644 lib/startup/windows_process_termination_io.dart create mode 100644 lib/startup/windows_process_termination_stub.dart diff --git a/lib/services/desktop_runtime_native.dart b/lib/services/desktop_runtime_native.dart index c18b932a..92f80c55 100644 --- a/lib/services/desktop_runtime_native.dart +++ b/lib/services/desktop_runtime_native.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart' show Size; import 'package:icarus/const/second_instance_args.dart'; +import 'package:icarus/startup/windows_process_termination.dart'; import 'package:window_manager/window_manager.dart'; import 'package:windows_single_instance/windows_single_instance.dart'; @@ -19,6 +20,7 @@ Future ensureIcarusSingleInstance( args, instanceId, onSecondWindow: publishSecondInstanceArgs, + exitFunction: terminateDuplicateWindowsProcess, ); } diff --git a/lib/startup/windows_process_termination.dart b/lib/startup/windows_process_termination.dart new file mode 100644 index 00000000..22b6e999 --- /dev/null +++ b/lib/startup/windows_process_termination.dart @@ -0,0 +1,2 @@ +export 'windows_process_termination_stub.dart' + if (dart.library.io) 'windows_process_termination_io.dart'; diff --git a/lib/startup/windows_process_termination_io.dart b/lib/startup/windows_process_termination_io.dart new file mode 100644 index 00000000..53c2f120 --- /dev/null +++ b/lib/startup/windows_process_termination_io.dart @@ -0,0 +1,22 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:win32/win32.dart'; + +Future terminateDuplicateWindowsProcess() { + if (!Platform.isWindows) { + throw UnsupportedError('Windows process termination requires Windows.'); + } + + // flutter_inappwebview_windows can crash in DLL_PROCESS_DETACH while + // releasing static WinRT state. This runs after argument forwarding and + // before Hive opens, so skipping DLL cleanup cannot interrupt library writes. + // See https://github.com/pichillilorenzo/flutter_inappwebview/issues/2733. + final result = TerminateProcess(GetCurrentProcess(), 0); + if (result == 0) { + throw WindowsException(HRESULT_FROM_WIN32(GetLastError())); + } + + // Keep startup suspended until Windows finishes terminating the process. + return Completer().future; +} diff --git a/lib/startup/windows_process_termination_stub.dart b/lib/startup/windows_process_termination_stub.dart new file mode 100644 index 00000000..ea3a436b --- /dev/null +++ b/lib/startup/windows_process_termination_stub.dart @@ -0,0 +1,3 @@ +Future terminateDuplicateWindowsProcess() async { + throw UnsupportedError('Windows process termination requires Windows.'); +} diff --git a/pubspec.lock b/pubspec.lock index 7eb0268c..bbf9557b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1592,7 +1592,7 @@ packages: source: hosted version: "3.1.0" win32: - dependency: transitive + dependency: "direct main" description: name: win32 sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e diff --git a/pubspec.yaml b/pubspec.yaml index 5e648383..4be3a327 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: custom_mouse_cursor: ^1.1.3 flutter_inappwebview: ^6.1.5 windows_single_instance: ^1.0.0 + win32: ^5.15.0 pasteboard: ^0.4.0 desktop_updater: ^1.4.0 cryptography_plus: ^2.7.1 From d24b6c2b61afcfa25be09c4b8bea8184b4236782 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:31:40 -0400 Subject: [PATCH 04/27] Fold cloud sync state into the editor save button and move the app to Lucide glyphs The strategy editor's floating controls become one card: save (or the cloud sync button, whose glyph is the sync state), export, video, screenshot, then settings. The text chip that spelled out sync state is gone; the promise now has a face on the save button, a tooltip for the words, and a popover with recovery only when sync needs attention. Material glyphs give way to Lucide across the library, editor, menus, dialogs and settings so every control draws from one icon family. Ghost buttons drop the command color so violet stays reserved for actions and selection. Shared strategies carry their role over the thumbnail like the on-this-device badge; owned cloud strategies, the ordinary case, carry none. The create dialog focuses its name field and submits on Enter. Co-Authored-By: Claude Fable 5.1 --- lib/main.dart | 5 + lib/providers/strategy_filter_provider.dart | 7 +- lib/sidebar.dart | 2 +- lib/strategy_view.dart | 15 +- ...tatus_chip.dart => cloud_sync_button.dart} | 169 +++----- lib/widgets/custom_search_field.dart | 5 +- lib/widgets/custom_text_field.dart | 3 + lib/widgets/demo_dialog.dart | 4 +- lib/widgets/dialogs/auth/auth_dialog.dart | 2 +- .../strategy/create_strategy_dialog.dart | 101 ++--- .../delete_strategy_alert_dialog.dart | 2 +- .../dialogs/strategy/line_up_media_page.dart | 13 +- .../strategy/rename_strategy_dialog.dart | 2 +- lib/widgets/dialogs/upload_image_dialog.dart | 6 +- .../ability_visibility_context_menu.dart | 6 +- .../adjacent_page_copy_menu.dart | 4 +- .../agents/agent_widget.dart | 9 +- .../placed_custom_rectangle_widget.dart | 3 +- .../utilities/view_cone_elevation_menu.dart | 7 +- lib/widgets/editor_toolbar.dart | 391 ++++++++++++++++++ lib/widgets/folder_card.dart | 21 +- lib/widgets/folder_content.dart | 16 +- lib/widgets/folder_edit_dialog.dart | 2 +- lib/widgets/folder_navigator.dart | 4 +- lib/widgets/folder_pill.dart | 18 +- lib/widgets/ica_drop_target.dart | 3 +- lib/widgets/image_drop_target.dart | 3 +- lib/widgets/library_breadcrumb.dart | 2 +- lib/widgets/library_title_strip.dart | 28 +- lib/widgets/line_up_media_carousel.dart | 2 +- lib/widgets/map_selector.dart | 3 +- lib/widgets/numeric_drag_input.dart | 2 +- lib/widgets/pages_bar.dart | 15 +- lib/widgets/role_badge.dart | 137 +++--- lib/widgets/save_and_load_button.dart | 289 ------------- lib/widgets/settings_tab.dart | 58 +-- .../sidebar_widgets/agent_dragable.dart | 4 +- .../sidebar_widgets/custom_shape_tools.dart | 8 +- .../sidebar_widgets/delete_options.dart | 12 +- .../sidebar_widgets/drawing_tools.dart | 4 +- lib/widgets/sidebar_widgets/tool_grid.dart | 14 +- lib/widgets/strategy_quick_switcher.dart | 4 +- lib/widgets/strategy_save_icon_button.dart | 205 ++++----- lib/widgets/strategy_tile/strategy_tile.dart | 18 +- .../strategy_tile/strategy_tile_sections.dart | 56 +-- lib/widgets/strategy_view_skeleton.dart | 31 +- lib/widgets/vision_boundary_editor.dart | 24 +- test/custom_shape_indicator_test.dart | 5 +- test/vision_boundary_editor_widget_test.dart | 2 +- ..._test.dart => cloud_sync_button_test.dart} | 84 ++-- 50 files changed, 936 insertions(+), 894 deletions(-) rename lib/widgets/{cloud_sync_status_chip.dart => cloud_sync_button.dart} (78%) create mode 100644 lib/widgets/editor_toolbar.dart delete mode 100644 lib/widgets/save_and_load_button.dart rename test/widgets/{cloud_sync_status_chip_test.dart => cloud_sync_button_test.dart} (90%) diff --git a/lib/main.dart b/lib/main.dart index 8159fc44..c9490f48 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -561,6 +561,11 @@ class _MyAppState extends ConsumerState { brightness: Brightness.dark, colorScheme: Settings.tacticalVioletTheme, breadcrumbTheme: const ShadBreadcrumbTheme(separatorSize: 18), + // Ghost buttons are quiet controls (menu items, icon buttons), + // not primary commands, so they don't get the command color. + ghostButtonTheme: ShadButtonTheme( + foregroundColor: Settings.tacticalVioletTheme.foreground, + ), ), home: const MyHomePage(), routes: { diff --git a/lib/providers/strategy_filter_provider.dart b/lib/providers/strategy_filter_provider.dart index df04479c..d0fb23a5 100644 --- a/lib/providers/strategy_filter_provider.dart +++ b/lib/providers/strategy_filter_provider.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; @@ -89,11 +90,11 @@ class StrategyFilterProvider extends Notifier { switch (state.sortBy) { case SortBy.alphabetical: - return (icon: Icons.sort_by_alpha, label: label); + return (icon: LucideIcons.arrowDownAZ, label: label); case SortBy.dateCreated: - return (icon: Icons.calendar_today, label: label); + return (icon: LucideIcons.calendarPlus, label: label); case SortBy.dateUpdated: - return (icon: Icons.update, label: label); + return (icon: LucideIcons.history, label: label); } } } diff --git a/lib/sidebar.dart b/lib/sidebar.dart index f0b0e753..950dad1b 100644 --- a/lib/sidebar.dart +++ b/lib/sidebar.dart @@ -103,7 +103,7 @@ class _SideBarUIState extends ConsumerState { .toggleFavoritesOnly(); }, icon: Icon( - Icons.star_rounded, + LucideIcons.star600, size: 24, color: filterState.favoritesOnly ? Colors.white diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart index 46f33a4c..ca1dbf61 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -23,7 +23,7 @@ import 'package:icarus/widgets/strategy_edit_boundary.dart'; import 'package:icarus/widgets/strategy_quick_switcher.dart'; import 'package:icarus/widgets/map_selector.dart'; import 'package:icarus/widgets/pages_bar.dart'; -import 'package:icarus/widgets/save_and_load_button.dart'; +import 'package:icarus/widgets/editor_toolbar.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/widgets/dialogs/create_lineup_dialog.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -231,10 +231,13 @@ class _StrategyViewState extends ConsumerState Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - ShadIconButton.ghost( - foregroundColor: Colors.white, - onPressed: _leaveToLibrary, - icon: const Icon(Icons.home), + ShadTooltip( + builder: (context) => const Text('Library'), + child: ShadIconButton.ghost( + foregroundColor: Colors.white, + onPressed: _leaveToLibrary, + icon: const Icon(LucideIcons.house, size: 20), + ), ), const SizedBox(width: 5), const StrategyEditBoundary( @@ -297,7 +300,7 @@ class _StrategyViewState extends ConsumerState alignment: Alignment.centerLeft, child: RepaintBoundary(child: InteractiveMap()), ), - Align(alignment: Alignment.topLeft, child: SaveAndLoadButton()), + Align(alignment: Alignment.topLeft, child: EditorToolbar()), Align( alignment: Alignment.bottomLeft, child: Padding( diff --git a/lib/widgets/cloud_sync_status_chip.dart b/lib/widgets/cloud_sync_button.dart similarity index 78% rename from lib/widgets/cloud_sync_status_chip.dart rename to lib/widgets/cloud_sync_button.dart index 27a25b09..a971bf5b 100644 --- a/lib/widgets/cloud_sync_status_chip.dart +++ b/lib/widgets/cloud_sync_button.dart @@ -13,28 +13,34 @@ import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_save_state_provider.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/widgets/editor_toolbar.dart'; +import 'package:icarus/widgets/strategy_save_icon_button.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; -const _chipSwitchDuration = Duration(milliseconds: 150); +const _glyphSwitchDuration = Duration(milliseconds: 150); const _conflictToastGap = Duration(seconds: 5); enum _SyncStatus { synced, editing, syncing, offline, attention } -/// Persistent cloud sync indicator for the strategy editor top strip. +/// The save button of a cloud strategy. Its glyph is the sync state, so the +/// promise that work is on the server has a face without a text chip: +/// synced, editing, syncing, offline, or needs attention. /// -/// Renders nothing for local strategies. For cloud strategies it shows one of -/// synced / syncing / offline / needs-attention, with a popover explaining the -/// state and offering recovery when something failed. Also surfaces conflicts -/// (the server rejected an edit while retaining the local intent) as a toast. -class CloudSyncStatusChip extends ConsumerStatefulWidget { - const CloudSyncStatusChip({super.key}); +/// Pressing it saves now. When sync needs attention the press opens a popover +/// that explains what happened and offers recovery instead. Conflicts (the +/// server rejected an edit while the local intent was kept) surface as a toast. +/// +/// Renders nothing for local strategies; [AutoSaveButton] covers those. +class CloudSyncButton extends ConsumerStatefulWidget { + const CloudSyncButton({super.key, required this.style}); + + final EditorToolbarButtonStyle style; @override - ConsumerState createState() => - _CloudSyncStatusChipState(); + ConsumerState createState() => _CloudSyncButtonState(); } -class _CloudSyncStatusChipState extends ConsumerState { +class _CloudSyncButtonState extends ConsumerState { final ShadPopoverController _popoverController = ShadPopoverController(); DateTime? _lastConflictToast; Timer? _pendingConflictToast; @@ -83,6 +89,14 @@ class _CloudSyncStatusChipState extends ConsumerState { ref.read(strategyConflictProvider.notifier).clearAll(); } + Future _handlePressed(_SyncStatus status) async { + if (status == _SyncStatus.attention) { + _popoverController.toggle(); + return; + } + await saveStrategyNow(context, ref); + } + Future _retry() async { if (_isResolving) return; setState(() { @@ -183,13 +197,18 @@ class _CloudSyncStatusChipState extends ConsumerState { CloudSyncStatus.attention => _SyncStatus.attention, }; + final tooltip = _tooltip(status, saveState.lastPersistedAt); + final foreground = status == _SyncStatus.attention + ? Settings.tacticalVioletTheme.destructive + : null; + return ShadPopover( controller: _popoverController, padding: const EdgeInsets.all(14), anchor: const ShadAnchor( offset: Offset(0, 8), - childAlignment: Alignment.topCenter, - overlayAlignment: Alignment.bottomCenter, + childAlignment: Alignment.topLeft, + overlayAlignment: Alignment.bottomLeft, ), popover: (context) => _SyncStatusPopover( status: status, @@ -203,136 +222,84 @@ class _CloudSyncStatusChipState extends ConsumerState { onRetry: _retry, onUseCloudVersions: _useCloudVersions, ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: _popoverController.toggle, - child: AnimatedContainer( - duration: _chipSwitchDuration, - curve: Curves.easeOutCubic, - height: 24, - padding: const EdgeInsets.symmetric(horizontal: 9), - decoration: BoxDecoration( - color: _chipBackground(status), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedSwitcher( - duration: _chipSwitchDuration, - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeOutCubic, - child: _chipIcon(status), - ), - const SizedBox(width: 6), - AnimatedSwitcher( - duration: _chipSwitchDuration, - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeOutCubic, - child: Text( - _chipLabel(status), - key: ValueKey(status), - style: TextStyle( - color: _chipForeground(status), - fontSize: 11, - fontWeight: FontWeight.w600, - letterSpacing: 0.3, - ), - ), - ), - ], - ), - ), - ), + child: EditorToolbarButton( + key: ValueKey('cloud-sync-button-${status.name}'), + style: widget.style, + tooltip: tooltip, + semanticsLabel: tooltip, + foregroundColor: foreground, + onPressed: () => _handlePressed(status), + icon: AnimatedSwitcher( + duration: _glyphSwitchDuration, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: _glyph(status, foreground), ), ), ); } - Color _chipBackground(_SyncStatus status) { - switch (status) { - case _SyncStatus.attention: - return Settings.tacticalVioletTheme.destructive.withValues(alpha: 0.14); - case _SyncStatus.offline: - case _SyncStatus.editing: - case _SyncStatus.syncing: - case _SyncStatus.synced: - return Settings.tacticalVioletTheme.muted.withValues(alpha: 0.4); - } - } - - Color _chipForeground(_SyncStatus status) { - switch (status) { - case _SyncStatus.attention: - return Settings.tacticalVioletTheme.destructive; - case _SyncStatus.offline: - case _SyncStatus.editing: - case _SyncStatus.syncing: - case _SyncStatus.synced: - return Settings.tacticalVioletTheme.mutedForeground; - } - } - - Widget _chipIcon(_SyncStatus status) { - final color = _chipForeground(status); + Widget _glyph(_SyncStatus status, Color? color) { + final size = widget.style.iconSize; switch (status) { case _SyncStatus.synced: return Icon( - Icons.cloud_done_outlined, + LucideIcons.cloudCheck, key: const ValueKey('synced'), - size: 13, + size: size, color: color, ); case _SyncStatus.editing: return Icon( - Icons.edit_outlined, + LucideIcons.cloudUpload, key: const ValueKey('editing'), - size: 13, + size: size, color: color, ); case _SyncStatus.syncing: return SizedBox( key: const ValueKey('syncing'), - width: 11, - height: 11, + width: size - 2, + height: size - 2, child: CircularProgressIndicator( - strokeWidth: 1.6, - valueColor: AlwaysStoppedAnimation(color), + strokeWidth: 1.8, + valueColor: AlwaysStoppedAnimation( + color ?? Settings.tacticalVioletTheme.mutedForeground, + ), ), ); case _SyncStatus.offline: return Icon( - Icons.cloud_off_outlined, + LucideIcons.cloudOff, key: const ValueKey('offline'), - size: 13, + size: size, color: color, ); case _SyncStatus.attention: return Icon( - Icons.error_outline, + LucideIcons.cloudAlert, key: const ValueKey('attention'), - size: 13, + size: size, color: color, ); } } - String _chipLabel(_SyncStatus status) { + /// One line, what the glyph means and what a press will do. + static String _tooltip(_SyncStatus status, DateTime? lastSynced) { switch (status) { case _SyncStatus.synced: - return 'Synced'; + return lastSynced == null + ? 'Synced' + : 'Synced at ${_SyncStatusPopover._formatTime(lastSynced)}'; case _SyncStatus.editing: - return 'Editing…'; + return 'Edit not synced yet'; case _SyncStatus.syncing: return 'Syncing…'; case _SyncStatus.offline: - return 'Offline'; + return 'Offline, changes stay on this device'; case _SyncStatus.attention: - return 'Needs attention'; + return 'Sync needs attention'; } } } diff --git a/lib/widgets/custom_search_field.dart b/lib/widgets/custom_search_field.dart index 12939e3c..37eeff9b 100644 --- a/lib/widgets/custom_search_field.dart +++ b/lib/widgets/custom_search_field.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/widgets/text_editing_shortcut_scope.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; /// A themed search text field that smoothly expands (slides out) when: /// - Hovered by the pointer @@ -171,7 +172,7 @@ class _SearchTextFieldState extends ConsumerState { ? const EdgeInsets.only(left: 8, right: 8) : const EdgeInsets.only(left: 12, right: 8), child: Icon( - Icons.search, + LucideIcons.search, color: Colors.white, size: compact ? 18 : 20, ), @@ -184,7 +185,7 @@ class _SearchTextFieldState extends ConsumerState { ? IconButton( tooltip: 'Clear', icon: Icon( - Icons.close, + LucideIcons.x, size: compact ? 18 : 20, color: Colors.white70, ), diff --git a/lib/widgets/custom_text_field.dart b/lib/widgets/custom_text_field.dart index 669b8f25..b15da7f6 100644 --- a/lib/widgets/custom_text_field.dart +++ b/lib/widgets/custom_text_field.dart @@ -17,6 +17,7 @@ class CustomTextField extends StatefulWidget { this.obscureText = false, this.textInputAction, this.hasError = false, + this.autofocus = false, }); final TextEditingController? controller; final FocusNode? focusNode; @@ -32,6 +33,7 @@ class CustomTextField extends StatefulWidget { /// Draws a destructive border when true (e.g. failed validation). final bool hasError; + final bool autofocus; @override State createState() => _CustomTextFieldState(); @@ -77,6 +79,7 @@ class _CustomTextFieldState extends State { : null, controller: _controller, focusNode: _focusNode, + autofocus: widget.autofocus, textAlign: widget.textAlign ?? TextAlign.start, minLines: widget.minLines, maxLines: widget.maxLines ?? 1, diff --git a/lib/widgets/demo_dialog.dart b/lib/widgets/demo_dialog.dart index 5034a8f4..aba1aea8 100644 --- a/lib/widgets/demo_dialog.dart +++ b/lib/widgets/demo_dialog.dart @@ -21,7 +21,7 @@ class DemoDialog extends ConsumerWidget { actions: [ ShadButton.secondary( leading: const Icon( - Icons.close, + LucideIcons.x, ), onPressed: () { Navigator.of(context).pop(); @@ -29,7 +29,7 @@ class DemoDialog extends ConsumerWidget { child: const Text('Close'), ), ShadButton( - leading: const Icon(Icons.download), + leading: const Icon(LucideIcons.download), onPressed: () async { await launchUrl(Settings.stableWindowsInstallerLink); }, diff --git a/lib/widgets/dialogs/auth/auth_dialog.dart b/lib/widgets/dialogs/auth/auth_dialog.dart index 9a5df12b..bfa0c57a 100644 --- a/lib/widgets/dialogs/auth/auth_dialog.dart +++ b/lib/widgets/dialogs/auth/auth_dialog.dart @@ -309,7 +309,7 @@ class _AuthMessageBanner extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( - isInfo ? Icons.mark_email_read_outlined : Icons.error_outline, + isInfo ? LucideIcons.mailCheck : LucideIcons.circleAlert, size: 15, color: color, ), diff --git a/lib/widgets/dialogs/strategy/create_strategy_dialog.dart b/lib/widgets/dialogs/strategy/create_strategy_dialog.dart index 3abf5a4a..c209c0ca 100644 --- a/lib/widgets/dialogs/strategy/create_strategy_dialog.dart +++ b/lib/widgets/dialogs/strategy/create_strategy_dialog.dart @@ -16,6 +16,7 @@ class CreateStrategyDialog extends ConsumerStatefulWidget { class _NameStrategyDialogState extends ConsumerState { final TextEditingController _textController = TextEditingController(); + bool _isSubmitting = false; @override void dispose() { @@ -23,80 +24,56 @@ class _NameStrategyDialogState extends ConsumerState { super.dispose(); } + Future _submit() async { + if (_isSubmitting) return; + final strategyName = _textController.text.trim(); + if (strategyName.isEmpty) { + Settings.showToast( + message: 'Strategy name cannot be empty.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + return; + } + + final isCloud = + ref.read(libraryWorkspaceProvider) == LibraryWorkspace.cloud; + setState(() => _isSubmitting = true); + try { + final strategyID = await ref + .read(strategyProvider.notifier) + .createNewStrategy(strategyName); + if (!mounted) return; + Navigator.of(context).pop(strategyID); + } catch (_) { + if (mounted) setState(() => _isSubmitting = false); + Settings.showToast( + message: isCloud + ? "Couldn't create cloud strategy right now. Please try logging in again." + : "Couldn't create strategy right now.", + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } + } + @override Widget build(BuildContext context) { - final isCloud = - ref.watch(libraryWorkspaceProvider) == LibraryWorkspace.cloud; return ShadDialog( - title: const Text("Create Strategy"), + title: const Text('Create Strategy'), actions: [ ShadButton( - child: const Text("Create"), - onPressed: () async { - final strategyName = _textController.text; - if (strategyName.isNotEmpty) { - try { - final strategyID = await ref - .read(strategyProvider.notifier) - .createNewStrategy(strategyName); - if (!context.mounted) return; - Navigator.of(context).pop(strategyID); // Close the dialog - } catch (_) { - Settings.showToast( - message: isCloud - ? "Couldn't create cloud strategy right now. Please try logging in again." - : "Couldn't create strategy right now.", - backgroundColor: Settings.tacticalVioletTheme.destructive, - ); - } - } else { - // Optionally, show an error message if the name is empty - Settings.showToast( - message: "Strategy name cannot be empty.", - backgroundColor: Settings.tacticalVioletTheme.destructive, - ); - } - }, - ) + onPressed: _isSubmitting ? null : _submit, + child: Text(_isSubmitting ? 'Creating…' : 'Create'), + ), ], child: SizedBox( width: 300, child: CustomTextField( - // onEnterPressed: (intent) {}, - hintText: "Enter strategy name", + hintText: 'Enter strategy name', controller: _textController, - - onSubmitted: (value) async { - if (value.isNotEmpty) { - try { - final strategyID = await ref - .read(strategyProvider.notifier) - .createNewStrategy(value); - if (!context.mounted) return; - Navigator.of(context).pop(strategyID); // Close the dialog - } catch (_) { - Settings.showToast( - message: isCloud - ? "Couldn't create cloud strategy right now. Please try logging in again." - : "Couldn't create strategy right now.", - backgroundColor: Settings.tacticalVioletTheme.destructive, - ); - } - } else { - // Optionally, show an error message if the name is empty - Settings.showToast( - message: "Strategy name cannot be empty.", - backgroundColor: Settings.tacticalVioletTheme.destructive, - ); - } - }, + autofocus: true, + onSubmitted: (_) => _submit(), ), ), ); } } -// How to use it: -// showDialog( -// context: context, -// builder: (context) => const NameStrategyDialog(), -// ); diff --git a/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart b/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart index 0eea1215..d8407aa9 100644 --- a/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart +++ b/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart @@ -124,7 +124,7 @@ class _DeleteStrategyAlertDialogState mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.delete_forever, + LucideIcons.trash2, color: Settings.tacticalVioletTheme.destructiveForeground, ), const SizedBox(width: 5), diff --git a/lib/widgets/dialogs/strategy/line_up_media_page.dart b/lib/widgets/dialogs/strategy/line_up_media_page.dart index 5a9d9c2b..2a095635 100644 --- a/lib/widgets/dialogs/strategy/line_up_media_page.dart +++ b/lib/widgets/dialogs/strategy/line_up_media_page.dart @@ -9,6 +9,7 @@ import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/widgets/custom_text_field.dart'; import 'package:path/path.dart' as path; +import 'package:shadcn_ui/shadcn_ui.dart'; class LineupMediaPage extends ConsumerStatefulWidget { final TextEditingController youtubeLinkController; @@ -113,7 +114,7 @@ class _LineupMediaPageState extends ConsumerState { child: Column( children: [ Icon( - Icons.add_photo_alternate_outlined, + LucideIcons.imagePlus, size: 48, color: Settings.tacticalVioletTheme.cardForeground, ), @@ -183,7 +184,7 @@ class _LineupMediaPageState extends ConsumerState { border: Border.all(color: Settings.tacticalVioletTheme.border), ), child: Icon( - Icons.add, + LucideIcons.plus, color: Settings.tacticalVioletTheme.secondaryForeground, ), ), @@ -206,7 +207,7 @@ class _LineupMediaPageState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.content_paste, + LucideIcons.clipboardPaste, color: Settings.tacticalVioletTheme.secondaryForeground, ), const SizedBox(height: 4), @@ -240,7 +241,7 @@ class _LineupMediaPageState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ Icon( - Icons.content_paste, + LucideIcons.clipboardPaste, color: Settings.tacticalVioletTheme.secondaryForeground, size: 16, ), @@ -288,7 +289,7 @@ class _LineupMediaPageState extends ConsumerState { child: imageProvider == null ? Center( child: Icon( - Icons.broken_image, + LucideIcons.imageOff, color: Settings.tacticalVioletTheme.secondaryForeground, ), ) @@ -307,7 +308,7 @@ class _LineupMediaPageState extends ConsumerState { color: Colors.black54, shape: BoxShape.circle, ), - child: const Icon(Icons.close, size: 14, color: Colors.white), + child: const Icon(LucideIcons.x, size: 14, color: Colors.white), ), ), ), diff --git a/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart b/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart index 971d2aba..2ffc901c 100644 --- a/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart +++ b/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart @@ -71,7 +71,7 @@ class _RenameStrategyDialogState extends ConsumerState { } }, height: 35, - leading: const Icon(Icons.text_fields), + leading: const Icon(LucideIcons.pencil), child: const Text("Rename"), ), ], diff --git a/lib/widgets/dialogs/upload_image_dialog.dart b/lib/widgets/dialogs/upload_image_dialog.dart index 0a68bed5..bf4859c1 100644 --- a/lib/widgets/dialogs/upload_image_dialog.dart +++ b/lib/widgets/dialogs/upload_image_dialog.dart @@ -333,9 +333,7 @@ class _EmptyState extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Icon( - isDragging - ? Icons.file_download_outlined - : Icons.add_photo_alternate_outlined, + isDragging ? LucideIcons.download : LucideIcons.imagePlus, size: 44, color: isDragging ? cs.primary : cs.onSurfaceVariant, ), @@ -404,7 +402,7 @@ class _SelectionFooter extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( children: [ - const Icon(Icons.image_outlined, size: 18), + const Icon(LucideIcons.image, size: 18), const SizedBox(width: 8), Expanded( child: Text( diff --git a/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart b/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart index 935ee176..552af07e 100644 --- a/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart +++ b/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart @@ -217,7 +217,8 @@ ShadContextMenuItem _buildToggleItem({ return ShadContextMenuItem( onPressed: onPressed, leading: Icon( - isEnabled ? Icons.check_box : Icons.check_box_outline_blank, + isEnabled ? LucideIcons.squareCheck : LucideIcons.square, + size: 16, ), child: Text(label), ); @@ -230,7 +231,8 @@ ShadContextMenuItem _buildDeleteItem( ) { return ShadContextMenuItem( leading: Icon( - Icons.delete, + LucideIcons.trash2, + size: 16, color: Settings.tacticalVioletTheme.destructive, ), child: const Text('Delete'), diff --git a/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart b/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart index adaa4ca2..a4f29e28 100644 --- a/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart +++ b/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart @@ -16,7 +16,7 @@ List buildAdjacentPageCopyMenuItems( return [ if (directions.contains(PageTransitionDirection.backward)) ShadContextMenuItem( - leading: const Icon(Icons.arrow_upward), + leading: const Icon(LucideIcons.arrowUp, size: 16), child: const Text('Copy to previous page'), onPressed: () async { await notifier.copyPlacedWidgetToAdjacentPage( @@ -27,7 +27,7 @@ List buildAdjacentPageCopyMenuItems( ), if (directions.contains(PageTransitionDirection.forward)) ShadContextMenuItem( - leading: const Icon(Icons.arrow_downward), + leading: const Icon(LucideIcons.arrowDown, size: 16), child: const Text('Copy to next page'), onPressed: () async { await notifier.copyPlacedWidgetToAdjacentPage( diff --git a/lib/widgets/draggable_widgets/agents/agent_widget.dart b/lib/widgets/draggable_widgets/agents/agent_widget.dart index b73ae89a..8c181d54 100644 --- a/lib/widgets/draggable_widgets/agents/agent_widget.dart +++ b/lib/widgets/draggable_widgets/agents/agent_widget.dart @@ -241,7 +241,7 @@ class AgentWidget extends ConsumerWidget { ), if (canInteract && lineUpId != null) ShadContextMenuItem( - leading: const Icon(LucideIcons.plus), + leading: const Icon(LucideIcons.plus, size: 16), child: const Text('Add Lineup Item'), onPressed: () { final group = @@ -259,7 +259,8 @@ class AgentWidget extends ConsumerWidget { if (canInteract && lineUpId != null) ShadContextMenuItem( leading: Icon( - Icons.delete, + LucideIcons.trash2, + size: 16, color: Settings.tacticalVioletTheme.destructive, ), child: const Text('Delete Lineup Group'), @@ -269,7 +270,7 @@ class AgentWidget extends ConsumerWidget { ), if (canInteract && viewConeAgent != null) ShadContextMenuItem( - leading: const Icon(LucideIcons.eyeOff), + leading: const Icon(LucideIcons.eyeOff, size: 16), child: const Text('Remove View Cone'), onPressed: () { ref.read(actionProvider.notifier).performTransaction( @@ -287,7 +288,7 @@ class AgentWidget extends ConsumerWidget { plainAgent != null && plainAgent.id.isNotEmpty) ShadContextMenuItem( - leading: const Icon(LucideIcons.plus), + leading: const Icon(LucideIcons.plus, size: 16), child: const Text('Create Lineup'), onPressed: () { ref diff --git a/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart b/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart index ef887d62..67e7f2e1 100644 --- a/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart +++ b/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart @@ -17,6 +17,7 @@ import 'package:icarus/widgets/draggable_widgets/utilities/custom_shape_resize_t import 'package:icarus/widgets/draggable_widgets/utilities/rectangle_axis_resize_geometry.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/shape_indicator_fade.dart'; import 'package:icarus/widgets/draggable_widgets/zoom_transform.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; enum _RectangleResizeHandle { none, left, right, top, bottom } @@ -841,7 +842,7 @@ class _RotationBadge extends StatelessWidget { curve: Curves.easeOutCubic, scale: isEmphasized ? 1.0 : 0.9, child: Icon( - Icons.rotate_right_rounded, + LucideIcons.rotateCw, size: size, color: isActive ? Settings.tacticalVioletTheme.primary : Colors.white, shadows: const [ diff --git a/lib/widgets/draggable_widgets/utilities/view_cone_elevation_menu.dart b/lib/widgets/draggable_widgets/utilities/view_cone_elevation_menu.dart index 096e8503..f36d0f0d 100644 --- a/lib/widgets/draggable_widgets/utilities/view_cone_elevation_menu.dart +++ b/lib/widgets/draggable_widgets/utilities/view_cone_elevation_menu.dart @@ -9,7 +9,7 @@ ShadContextMenuItem buildViewConeElevationMenuItem({ required ValueChanged onChanged, }) { return ShadContextMenuItem( - leading: const Icon(Icons.layers_outlined), + leading: const Icon(LucideIcons.layers, size: 16), trailing: Text( selectedElevation == null ? 'Auto ${formatVisionElevation(automaticElevation)}' @@ -45,7 +45,7 @@ ShadContextMenuItem buildViewConeDebugMenuItem({ required ValueChanged onChanged, }) { return ShadContextMenuItem( - leading: Icon(enabled ? Icons.visibility : Icons.visibility_outlined), + leading: Icon(enabled ? LucideIcons.eye : LucideIcons.eyeOff, size: 16), trailing: Text(enabled ? 'On' : 'Off'), onPressed: () => onChanged(!enabled), child: const Text('Vision calibration'), @@ -59,7 +59,8 @@ ShadContextMenuItem _elevationItem({ }) { return ShadContextMenuItem( leading: Icon( - selected ? Icons.radio_button_checked : Icons.radio_button_off, + selected ? LucideIcons.circleDot : LucideIcons.circle, + size: 16, ), onPressed: onPressed, child: Text(label), diff --git a/lib/widgets/editor_toolbar.dart b/lib/widgets/editor_toolbar.dart new file mode 100644 index 00000000..643b8594 --- /dev/null +++ b/lib/widgets/editor_toolbar.dart @@ -0,0 +1,391 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; +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_page_session_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/cloud_strategy_export.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/screenshot/offscreen_capture.dart'; +import 'package:icarus/screenshot/screenshot_view.dart'; +import 'package:icarus/widgets/cloud_sync_button.dart'; +import 'package:icarus/widgets/dialogs/export_video_dialog.dart'; +import 'package:icarus/widgets/settings_tab.dart'; +import 'package:icarus/widgets/strategy_save_icon_button.dart'; +import 'package:screenshot/screenshot.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// Geometry shared by every control in the editor's floating toolbar, so the +/// save button (which swaps between local and cloud variants) matches its +/// neighbours exactly. +class EditorToolbarButtonStyle { + const EditorToolbarButtonStyle({ + this.size = 32, + this.iconSize = 18, + }); + + final double size; + final double iconSize; +} + +const EditorToolbarButtonStyle kEditorToolbarButtonStyle = + EditorToolbarButtonStyle(); + +/// The document actions of the open strategy, docked at the top-left of the +/// canvas as one card: save, export, video, screenshot, then settings. +class EditorToolbar extends ConsumerStatefulWidget { + const EditorToolbar({super.key}); + + @override + ConsumerState createState() => _EditorToolbarState(); +} + +class _EditorToolbarState extends ConsumerState { + bool _isCapturingScreenshot = false; + + @override + Widget build(BuildContext context) { + const style = kEditorToolbarButtonStyle; + final source = ref.watch(strategyProvider.select((value) => value.source)); + final isCloud = source == StrategySource.cloud; + + return Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.card, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Settings.tacticalVioletTheme.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isCloud) + const CloudSyncButton(style: style) + else + const AutoSaveButton(style: style), + EditorToolbarButton( + style: style, + tooltip: 'Export .ica', + onPressed: _exportStrategy, + icon: const Icon(LucideIcons.upload), + ), + EditorToolbarButton( + style: style, + tooltip: 'Export video', + onPressed: _exportVideo, + icon: const Icon(LucideIcons.clapperboard), + ), + EditorToolbarButton( + style: style, + tooltip: 'Screenshot', + onPressed: _captureScreenshot, + icon: _isCapturingScreenshot + ? SizedBox( + width: style.iconSize - 2, + height: style.iconSize - 2, + child: CircularProgressIndicator( + strokeWidth: 1.8, + valueColor: AlwaysStoppedAnimation( + Settings.tacticalVioletTheme.mutedForeground, + ), + ), + ) + : const Icon(LucideIcons.camera), + ), + const EditorToolbarDivider(), + EditorToolbarButton( + style: style, + tooltip: 'Settings', + onPressed: () { + showShadDialog( + context: context, + builder: (context) => const SettingsTab(), + ); + }, + icon: const Icon(LucideIcons.settings), + ), + ], + ), + ), + if (_isViewOnly()) ...[ + const SizedBox(width: 8), + const _ViewOnlyChip(), + ], + ], + ), + ); + } + + void _showDesktopOnlyToast() { + Settings.showToast( + message: 'This feature is only supported in the desktop app.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } + + Future _exportStrategy() async { + if (kIsWeb) { + _showDesktopOnlyToast(); + return; + } + + final strategy = ref.read(strategyProvider); + final strategyId = strategy.strategyId; + if (strategyId == null || strategy.source == null) { + throw StateError('No strategy is open for export.'); + } + + final exporter = StrategyImportExportService(ref); + switch (strategy.source!) { + case StrategySource.cloud: + await runCloudStrategyExport(ref, strategyId); + case StrategySource.local: + await exporter.exportFile(strategyId); + } + } + + void _exportVideo() { + if (kIsWeb) { + _showDesktopOnlyToast(); + return; + } + showShadDialog( + context: context, + builder: (context) => const ExportVideoDialog(), + ); + } + + Future _captureScreenshot() async { + if (kIsWeb) { + _showDesktopOnlyToast(); + return; + } + if (_isCapturingScreenshot) return; + setState(() => _isCapturingScreenshot = true); + CoordinateSystem.instance.setIsScreenshot(true); + + final String id = ref.read(strategyProvider).strategyId!; + + await ref.read(strategyProvider.notifier).forceSaveNow(id); + + final newStrat = Hive.box(HiveBoxNames.strategiesBox) + .values + .where((StrategyData strategy) => strategy.id == id) + .firstOrNull; + + if (newStrat == null) { + if (mounted) setState(() => _isCapturingScreenshot = false); + CoordinateSystem.instance.setIsScreenshot(false); + return; + } + final newController = ScreenshotController(); + final mapState = ref.read(mapProvider); + final currentPageID = ref.read(strategyPageSessionProvider).activePageId; + + if (currentPageID == null) { + if (mounted) setState(() => _isCapturingScreenshot = false); + CoordinateSystem.instance.setIsScreenshot(false); + return; + } + + final activePage = newStrat.pages.firstWhere( + (p) => p.id == currentPageID, + orElse: () => newStrat.pages.first, + ); + final screenshotContainer = ProviderContainer(); + + try { + final screenshotView = ScreenshotView( + isAttack: activePage.isAttack, + mapValue: newStrat.mapData, + showSpawnBarrier: mapState.showSpawnBarrier, + showRegionNames: mapState.showRegionNames, + showUltOrbs: mapState.showUltOrbs, + agents: activePage.agentData, + abilities: activePage.abilityData, + text: activePage.textData, + images: activePage.imageData, + drawings: activePage.drawingData, + utilities: activePage.utilityData, + strategySettings: activePage.settings, + strategyState: ref.read(strategyProvider), + pageName: activePage.name, + lineUpGroups: activePage.lineUpGroups, + themeProfileId: newStrat.themeProfileId, + themeOverridePalette: newStrat.themeOverridePalette, + ); + screenshotView.hydrateProviders(screenshotContainer); + final image = await newController.captureFromWidget( + targetSize: CoordinateSystem.screenShotSize, + wrapForOffscreenCapture( + screenshotView, + container: screenshotContainer, + ), + ); + if (mounted) setState(() => _isCapturingScreenshot = false); + String? outputFile = await FilePicker.platform.saveFile( + type: FileType.custom, + dialogTitle: 'Please select an output file:', + fileName: + "${ref.read(strategyProvider).strategyName ?? "new image"}.png", + allowedExtensions: ['png'], + ); + if (outputFile != null) { + final file = File(outputFile); + await file.writeAsBytes(image); + } + } catch (_) { + } finally { + screenshotContainer.dispose(); + if (mounted && _isCapturingScreenshot) { + setState(() => _isCapturingScreenshot = false); + } + ref.read(screenshotProvider.notifier).setIsScreenShot(false); + CoordinateSystem.instance.setIsScreenshot(false); + ref + .read(drawingProvider.notifier) + .rebuildAllPaths(CoordinateSystem.instance); + } + } + + bool _isViewOnly() { + final source = ref.watch(strategyProvider.select((value) => value.source)); + if (source != StrategySource.cloud) { + return false; + } + // Read the cached role rather than the raw snapshot so the chip does not + // flicker off while the snapshot is reloading or transiently errored; it + // is absent only before the role has ever been known. + final role = ref.watch(lastKnownCloudRoleProvider); + return role == 'viewer'; + } +} + +/// One control in the editor toolbar: a quiet ghost icon button that comes up +/// to full foreground on hover. [icon] is any 18px glyph, so buttons can swap +/// in a spinner without changing size. +class EditorToolbarButton extends StatelessWidget { + const EditorToolbarButton({ + super.key, + required this.style, + required this.tooltip, + required this.icon, + required this.onPressed, + this.enabled = true, + this.foregroundColor, + this.semanticsLabel, + }); + + final EditorToolbarButtonStyle style; + final String tooltip; + final Widget icon; + final VoidCallback? onPressed; + final bool enabled; + + /// Overrides the resting color, e.g. destructive for a sync problem. + final Color? foregroundColor; + final String? semanticsLabel; + + @override + Widget build(BuildContext context) { + const theme = Settings.tacticalVioletTheme; + final resting = foregroundColor ?? theme.mutedForeground; + return Semantics( + label: semanticsLabel ?? tooltip, + button: true, + enabled: enabled, + onTap: enabled ? onPressed : null, + excludeSemantics: true, + child: ShadTooltip( + builder: (context) => Text(tooltip), + child: IconTheme( + data: IconThemeData(size: style.iconSize, color: resting), + child: ShadIconButton.ghost( + width: style.size, + height: style.size, + enabled: enabled, + foregroundColor: resting, + hoverForegroundColor: foregroundColor ?? theme.foreground, + hoverBackgroundColor: theme.accent, + onPressed: onPressed, + icon: icon, + ), + ), + ), + ); + } +} + +/// A 1px hairline between groups of toolbar controls. +class EditorToolbarDivider extends StatelessWidget { + const EditorToolbarDivider({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + width: 1, + height: 18, + margin: const EdgeInsets.symmetric(horizontal: 4), + color: Settings.tacticalVioletTheme.border, + ); + } +} + +/// Non-interactive chip shown beside the toolbar when the open cloud strategy +/// is shared with view-only access. +class _ViewOnlyChip extends StatelessWidget { + const _ViewOnlyChip(); + + @override + Widget build(BuildContext context) { + const theme = Settings.tacticalVioletTheme; + + return ShadTooltip( + builder: (context) => const Text( + 'You have view access. Ask the owner for edit access to make changes.', + ), + child: Container( + height: 32, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: theme.card, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.eye, size: 14, color: theme.mutedForeground), + const SizedBox(width: 6), + Text( + 'View only', + style: TextStyle( + color: theme.mutedForeground, + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + height: 1.2, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/folder_card.dart b/lib/widgets/folder_card.dart index 83a53f90..951a5298 100644 --- a/lib/widgets/folder_card.dart +++ b/lib/widgets/folder_card.dart @@ -551,7 +551,7 @@ class _FolderCardState extends ConsumerState if (isPinned) ...[ const SizedBox(width: 4), Icon( - Icons.push_pin, + LucideIcons.pin, color: Colors.white.withValues(alpha: 0.78), size: 13, ), @@ -624,7 +624,7 @@ class _FolderCardState extends ConsumerState highlightColor: Colors.white.withValues(alpha: 0.08), onTap: _handleMenuButtonPressed, child: Icon( - Icons.more_vert, + LucideIcons.ellipsisVertical, color: Colors.white.withValues(alpha: iconAlpha), size: 16, ), @@ -639,7 +639,7 @@ class _FolderCardState extends ConsumerState final id = _folder.id; return [ ShadContextMenuItem( - leading: Icon(isPinned ? Icons.push_pin : Icons.push_pin_outlined), + leading: Icon(isPinned ? LucideIcons.pinOff : LucideIcons.pin), child: Text(isPinned ? 'Unpin' : 'Pin'), onPressed: () { _closeMenus(); @@ -648,7 +648,7 @@ class _FolderCardState extends ConsumerState }, ), ShadContextMenuItem( - leading: const Icon(Icons.text_fields), + leading: const Icon(LucideIcons.pencil), child: const Text('Edit'), onPressed: () async { _closeMenus(); @@ -663,7 +663,7 @@ class _FolderCardState extends ConsumerState }, ), ShadContextMenuItem( - leading: const Icon(Icons.file_upload), + leading: const Icon(LucideIcons.upload), child: const Text('Export'), onPressed: () async { _closeMenus(); @@ -671,8 +671,11 @@ class _FolderCardState extends ConsumerState }, ), ShadContextMenuItem( - leading: const Icon(Icons.delete, color: Colors.redAccent), - child: const Text('Delete', style: TextStyle(color: Colors.redAccent)), + leading: Icon(LucideIcons.trash2, + color: Settings.tacticalVioletTheme.destructive), + child: Text('Delete', + style: + TextStyle(color: Settings.tacticalVioletTheme.destructive)), onPressed: () async { _closeMenus(); if (widget.isDemo) return; @@ -842,7 +845,7 @@ class _CountBadge extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ if (folderCount > 0) ...[ - Icon(Icons.folder_outlined, size: 12, color: muted), + Icon(LucideIcons.folder, size: 12, color: muted), const SizedBox(width: 2), Text( '$folderCount', @@ -852,7 +855,7 @@ class _CountBadge extends StatelessWidget { ], if (folderCount > 0 && strategyCount > 0) const SizedBox(width: 6), if (strategyCount > 0) ...[ - Icon(Icons.description_outlined, size: 12, color: muted), + Icon(LucideIcons.fileText, size: 12, color: muted), const SizedBox(width: 2), Text( '$strategyCount', diff --git a/lib/widgets/folder_content.dart b/lib/widgets/folder_content.dart index 1fc2a0d3..602824b0 100644 --- a/lib/widgets/folder_content.dart +++ b/lib/widgets/folder_content.dart @@ -310,7 +310,7 @@ class FolderContent extends ConsumerWidget { acceptsIcaDrops: true, banner: cloudFailed ? _CloudErrorBanner(onRetry: () => _retryCloud(ref)) : null, emptyStateKey: const ValueKey('library-empty-state'), - emptyStateIcon: Icons.folder_outlined, + emptyStateIcon: LucideIcons.folder, emptyStateTitle: 'Your library is empty', emptyStateSubtitle: cloudAvailable ? 'Create your first strategy to keep it available across your ' @@ -320,7 +320,7 @@ class FolderContent extends ConsumerWidget { emptyStateAction: ShadButton( key: const ValueKey('library-empty-create-strategy'), onPressed: onCreateStrategy, - leading: const Icon(Icons.add), + leading: const Icon(LucideIcons.plus), child: const Text('Create Strategy'), ), ), @@ -385,7 +385,7 @@ class FolderContent extends ConsumerWidget { ? const ValueKey('shared-empty-state') : null, emptyStateIcon: isSharedWithMe && folder == null - ? Icons.people_outline + ? LucideIcons.users : null, emptyStateTitle: isSharedWithMe && folder == null ? 'Nothing shared with you yet' @@ -407,7 +407,7 @@ class FolderContent extends ConsumerWidget { : ShadButton( key: const ValueKey('cloud-empty-create-strategy'), onPressed: onCreateStrategy, - leading: const Icon(Icons.add), + leading: const Icon(LucideIcons.plus), child: const Text('Create Strategy'), ), ), @@ -639,7 +639,7 @@ class FolderContent extends ConsumerWidget { Widget _buildCloudUnavailableState(BuildContext context, WidgetRef ref) { return _LibraryMessageState( - icon: Icons.cloud_off_outlined, + icon: LucideIcons.cloudOff, iconColor: Settings.tacticalVioletTheme.mutedForeground, title: 'Cloud unavailable', subtitle: 'Sign in again to reach your online strategies, or go back ' @@ -664,7 +664,7 @@ class FolderContent extends ConsumerWidget { Widget _buildCloudErrorState(BuildContext context, WidgetRef ref) { return _LibraryMessageState( - icon: Icons.cloud_off_outlined, + icon: LucideIcons.cloudOff, iconColor: Settings.tacticalVioletTheme.destructive, title: "Couldn't load your cloud library", subtitle: 'Check your connection and try again.', @@ -684,7 +684,7 @@ class FolderContent extends ConsumerWidget { Widget _buildCommunityPlaceholder(BuildContext context, WidgetRef ref) { return _LibraryMessageState( - icon: Icons.public, + icon: LucideIcons.globe, iconColor: Settings.tacticalVioletTheme.primary, title: 'Community strats are coming soon', subtitle: @@ -721,7 +721,7 @@ class _CloudErrorBanner extends StatelessWidget { child: Row( children: [ Icon( - Icons.cloud_off_outlined, + LucideIcons.cloudOff, size: 16, color: Settings.tacticalVioletTheme.destructive, ), diff --git a/lib/widgets/folder_edit_dialog.dart b/lib/widgets/folder_edit_dialog.dart index ed8a1f42..566d4fb9 100644 --- a/lib/widgets/folder_edit_dialog.dart +++ b/lib/widgets/folder_edit_dialog.dart @@ -141,7 +141,7 @@ class _FolderEditDialogState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: ShadButton( key: const ValueKey('folder-edit-submit'), - leading: _isSubmitting ? null : const Icon(Icons.check), + leading: _isSubmitting ? null : const Icon(LucideIcons.check), onPressed: _isSubmitting ? null : _submit, child: Text(_isSubmitting ? 'Saving...' : 'Done'), ), diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index 303c00c7..5e721e04 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -329,12 +329,12 @@ class _FolderNavigatorState extends ConsumerState { : [ ShadContextMenuItem( leading: - const Icon(Icons.create_new_folder_outlined), + const Icon(LucideIcons.folderPlus), onPressed: showCreateFolderDialog, child: const Text('Create Folder'), ), ShadContextMenuItem( - leading: const Icon(Icons.note_add_outlined), + leading: const Icon(LucideIcons.filePlus), onPressed: showCreateDialog, child: const Text('Create Strategy'), ), diff --git a/lib/widgets/folder_pill.dart b/lib/widgets/folder_pill.dart index c028dc41..db9c2bb8 100644 --- a/lib/widgets/folder_pill.dart +++ b/lib/widgets/folder_pill.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/folder_icons.dart'; +import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/collab/remote_library_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/library_context_menu_provider.dart'; @@ -317,7 +318,7 @@ class _FolderPillState extends ConsumerState if (isPinned) ...[ const SizedBox(width: 6), Icon( - Icons.push_pin, + LucideIcons.pin, color: Colors.white.withValues(alpha: 0.78), size: 14, ), @@ -381,7 +382,7 @@ class _FolderPillState extends ConsumerState highlightColor: Colors.white.withValues(alpha: 0.08), onTap: _handleMenuButtonPressed, child: Icon( - Icons.more_vert, + LucideIcons.ellipsisVertical, color: Colors.white.withValues(alpha: iconAlpha), size: 18, ), @@ -396,7 +397,7 @@ class _FolderPillState extends ConsumerState final id = widget.folder.id; return [ ShadContextMenuItem( - leading: Icon(isPinned ? Icons.push_pin : Icons.push_pin_outlined), + leading: Icon(isPinned ? LucideIcons.pinOff : LucideIcons.pin), child: Text(isPinned ? 'Unpin' : 'Pin'), onPressed: () { _closeMenus(); @@ -405,7 +406,7 @@ class _FolderPillState extends ConsumerState }, ), ShadContextMenuItem( - leading: const Icon(Icons.text_fields), + leading: const Icon(LucideIcons.pencil), child: const Text('Edit'), onPressed: !_canManageCloudFolder ? null @@ -439,7 +440,7 @@ class _FolderPillState extends ConsumerState }, ), ShadContextMenuItem( - leading: const Icon(Icons.file_upload), + leading: const Icon(LucideIcons.upload), child: const Text('Export'), onPressed: () async { _closeMenus(); @@ -447,8 +448,11 @@ class _FolderPillState extends ConsumerState }, ), ShadContextMenuItem( - leading: const Icon(Icons.delete, color: Colors.redAccent), - child: const Text('Delete', style: TextStyle(color: Colors.redAccent)), + leading: Icon(LucideIcons.trash2, + color: Settings.tacticalVioletTheme.destructive), + child: Text('Delete', + style: + TextStyle(color: Settings.tacticalVioletTheme.destructive)), onPressed: !_canManageCloudFolder ? null : () async { diff --git a/lib/widgets/ica_drop_target.dart b/lib/widgets/ica_drop_target.dart index d8cca8d2..7b3163c1 100644 --- a/lib/widgets/ica_drop_target.dart +++ b/lib/widgets/ica_drop_target.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/services/app_error_reporter.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; String buildImportSummaryMessage(ImportBatchResult result) { final skippedCount = result.issues.length; @@ -144,7 +145,7 @@ class _CustomDropTargetState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.download, size: 60), + Icon(LucideIcons.download, size: 60), SizedBox( height: 10, ), diff --git a/lib/widgets/image_drop_target.dart b/lib/widgets/image_drop_target.dart index 17ec4a60..47d490bc 100644 --- a/lib/widgets/image_drop_target.dart +++ b/lib/widgets/image_drop_target.dart @@ -6,6 +6,7 @@ import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; class ImageDropTarget extends ConsumerStatefulWidget { const ImageDropTarget({super.key, required this.child}); @@ -85,7 +86,7 @@ class _ImageDropTargetState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.download, size: 60), + Icon(LucideIcons.download, size: 60), SizedBox( height: 10, ), diff --git a/lib/widgets/library_breadcrumb.dart b/lib/widgets/library_breadcrumb.dart index 1f263295..c265751f 100644 --- a/lib/widgets/library_breadcrumb.dart +++ b/lib/widgets/library_breadcrumb.dart @@ -47,7 +47,7 @@ class LibraryBreadcrumb extends ConsumerWidget { ref.read(folderProvider.notifier).updateID(parent.id); } }, - icon: const Icon(Icons.chevron_left, size: 20), + icon: const Icon(LucideIcons.chevronLeft, size: 20), ), const SizedBox(width: 4), Expanded( diff --git a/lib/widgets/library_title_strip.dart b/lib/widgets/library_title_strip.dart index a135128b..eba8165d 100644 --- a/lib/widgets/library_title_strip.dart +++ b/lib/widgets/library_title_strip.dart @@ -17,7 +17,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; const double _controlHeight = 28; // Action menus hug their labels; the account menu keeps room for email text. -const double _sortMenuWidth = 132; +const double _sortMenuWidth = 168; const double _newMenuWidth = 140; const double _accountMenuWidth = 200; const double _menuItemHorizontalPadding = 8; @@ -173,7 +173,7 @@ class _LibraryTitleStripState extends ConsumerState { for (final value in SortBy.values) _MenuItem( menu: _sortController, - icon: value == filter.sortBy ? Icons.check : null, + icon: value == filter.sortBy ? LucideIcons.check : null, label: StrategyFilterProvider.sortByLabels[value]!, onPressed: () { ref.read(strategyFilterProvider.notifier).setSortBy(value); @@ -233,7 +233,7 @@ class _LibraryTitleStripState extends ConsumerState { _MenuItem( menu: _newController, key: const ValueKey('library-new-strategy'), - icon: Icons.note_add_outlined, + icon: LucideIcons.filePlus, label: 'New Strategy', onPressed: widget.onCreateStrategy, ), @@ -248,19 +248,19 @@ class _LibraryTitleStripState extends ConsumerState { const _MenuDivider(), _MenuItem( menu: _newController, - icon: Icons.file_download_outlined, + icon: LucideIcons.fileDown, label: 'Import .ica', onPressed: widget.onImportIca, ), _MenuItem( menu: _newController, - icon: Icons.archive_outlined, + icon: LucideIcons.archiveRestore, label: 'Import Backup', onPressed: widget.onImportBackup, ), _MenuItem( menu: _newController, - icon: Icons.backup_outlined, + icon: LucideIcons.archive, label: 'Export Library', onPressed: widget.onExportLibrary, ), @@ -273,8 +273,8 @@ class _LibraryTitleStripState extends ConsumerState { height: _controlHeight, padding: const EdgeInsets.only(left: 8, right: 6), onPressed: _newController.toggle, - leading: const Icon(Icons.add, size: 16), - trailing: const Icon(Icons.keyboard_arrow_down, size: 16), + leading: const Icon(LucideIcons.plus, size: 16), + trailing: const Icon(LucideIcons.chevronDown, size: 14), child: const Text('New'), ), ); @@ -370,7 +370,7 @@ class _LibraryTitleStripState extends ConsumerState { radius: 12, backgroundColor: Settings.tacticalVioletTheme.secondary, avatarUrl: auth.avatarUrl, - fallback: const Icon(Icons.person, size: 14), + fallback: const Icon(LucideIcons.user, size: 14), ), ), ), @@ -459,7 +459,15 @@ class _MenuItem extends StatelessWidget { }, leading: SizedBox( width: _menuIconWidth, - child: icon == null ? null : Icon(icon, size: 16), + child: icon == null + ? null + : Icon( + icon, + size: 16, + color: icon == LucideIcons.check + ? Settings.tacticalVioletTheme.primary + : Settings.tacticalVioletTheme.mutedForeground, + ), ), child: Flexible( child: Text( diff --git a/lib/widgets/line_up_media_carousel.dart b/lib/widgets/line_up_media_carousel.dart index 7f6c4e1c..a37da055 100644 --- a/lib/widgets/line_up_media_carousel.dart +++ b/lib/widgets/line_up_media_carousel.dart @@ -122,7 +122,7 @@ class _ImageCarouselState extends ConsumerState if (!file.existsSync() && (remoteUrl == null || remoteUrl.isEmpty)) { return const Center( - child: Icon(Icons.broken_image, color: Colors.white), + child: Icon(LucideIcons.imageOff, color: Colors.white), ); } diff --git a/lib/widgets/map_selector.dart b/lib/widgets/map_selector.dart index ca20e482..edb8f2a9 100644 --- a/lib/widgets/map_selector.dart +++ b/lib/widgets/map_selector.dart @@ -6,6 +6,7 @@ import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/widgets/map_tile.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; class MapSelector extends ConsumerStatefulWidget { const MapSelector({super.key}); @@ -222,7 +223,7 @@ class _MapSelectorState extends ConsumerState { Icon( (ref.watch(mapProvider).isAttack) ? CustomIcons.sword - : Icons.shield, + : LucideIcons.shield, size: 20, color: (ref.watch(mapProvider).isAttack) ? Colors.redAccent diff --git a/lib/widgets/numeric_drag_input.dart b/lib/widgets/numeric_drag_input.dart index 43aa1de7..1ec47759 100644 --- a/lib/widgets/numeric_drag_input.dart +++ b/lib/widgets/numeric_drag_input.dart @@ -321,7 +321,7 @@ class _NumericDragInputState extends State { child: Padding( padding: trailingPadding, child: Icon( - Icons.drag_indicator, + LucideIcons.gripVertical, size: widget.dragIconSize, color: _isDragging ? colorScheme.primary diff --git a/lib/widgets/pages_bar.dart b/lib/widgets/pages_bar.dart index d595a42a..6037e7f7 100644 --- a/lib/widgets/pages_bar.dart +++ b/lib/widgets/pages_bar.dart @@ -237,7 +237,7 @@ class _PagesBarState extends ConsumerState { ), ShadButton( onPressed: () => Navigator.of(ctx).pop(controller.text.trim()), - leading: const Icon(Icons.text_fields), + leading: const Icon(LucideIcons.type, size: 16), child: const Text("Rename"), ), ], @@ -495,7 +495,7 @@ class _CollapsedPill extends StatelessWidget { children: [ const SizedBox(width: _pagesBarControlInset), _SquareIconButton( - icon: Icons.add, + icon: LucideIcons.plus, onTap: onAdd, tooltip: "Add page", color: Settings.tacticalVioletTheme.primary, @@ -518,7 +518,8 @@ class _CollapsedPill extends StatelessWidget { padding: EdgeInsets.zero, foregroundColor: Colors.white, onPressed: onToggle, - icon: const Icon(Icons.keyboard_arrow_down, color: Colors.white), + icon: const Icon(LucideIcons.chevronDown, + size: 20, color: Colors.white), decoration: ShadDecoration( border: ShadBorder( radius: BorderRadius.circular(_pagesBarInnerButtonRadius), @@ -714,7 +715,7 @@ class _ExpandedPanel extends ConsumerWidget { children: [ const SizedBox(width: _pagesBarControlInset), _SquareIconButton( - icon: Icons.add, + icon: LucideIcons.plus, onTap: canAddPage ? onAdd : null, tooltip: "Add page", color: Settings.tacticalVioletTheme.primary, @@ -727,8 +728,8 @@ class _ExpandedPanel extends ConsumerWidget { padding: EdgeInsets.zero, foregroundColor: Colors.white, onPressed: onCollapse, - icon: - const Icon(Icons.keyboard_arrow_up, color: Colors.white), + icon: const Icon(LucideIcons.chevronUp, + size: 20, color: Colors.white), decoration: ShadDecoration( border: ShadBorder( radius: BorderRadius.circular( @@ -1090,7 +1091,7 @@ class _SquareIconButton extends StatelessWidget { backgroundColor: color, hoverBackgroundColor: color, foregroundColor: Colors.white, - icon: Icon(icon), + icon: Icon(icon, size: 20), width: _pagesBarControlSize, height: _pagesBarControlSize, padding: EdgeInsets.zero, diff --git a/lib/widgets/role_badge.dart b/lib/widgets/role_badge.dart index 682b90a3..b2368d4a 100644 --- a/lib/widgets/role_badge.dart +++ b/lib/widgets/role_badge.dart @@ -1,11 +1,9 @@ import 'package:flutter/material.dart'; import 'package:icarus/const/settings.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; -/// Small uppercase micro badges used to mark cloud ownership and share role. -/// -/// Styling follows the Icarus design system: 10px, w600, 0.5 letter spacing, -/// uppercase text on a tinted pill. Colors come only from the tactical theme -/// tokens (no hardcoded Material colors). +/// What the cloud says about a strategy's place in the library: yours, shared +/// with edit access, or shared read-only. enum CloudBadgeKind { owned, editor, viewer } CloudBadgeKind? cloudBadgeKindForRole(String? role) { @@ -26,87 +24,90 @@ CloudBadgeKind? cloudBadgeKindForRole(String? role) { } } -class CloudRoleBadge extends StatelessWidget { - const CloudRoleBadge({super.key, required this.kind}); +/// The pill drawn over a strategy thumbnail to say where it lives or how it +/// was shared. Owned cloud strategies are the ordinary case in a signed-in +/// library, so they carry no badge; only the exceptions get one. +class StrategyBadge extends StatelessWidget { + const StrategyBadge({ + super.key, + required this.icon, + required this.label, + required this.tooltip, + }); - final CloudBadgeKind kind; + final IconData icon; + final String label; + final String tooltip; @override Widget build(BuildContext context) { const theme = Settings.tacticalVioletTheme; - - switch (kind) { - case CloudBadgeKind.owned: - // Owned cloud strategies: a muted cloud glyph pill, deliberately quiet. - return _BadgePill( - background: theme.muted, - border: theme.border, - child: Icon( - Icons.cloud_outlined, - size: 12, - color: theme.mutedForeground, - ), - ); - case CloudBadgeKind.editor: - // Shared with edit access: violet tint to echo the "action" accent. - return _BadgePill( - background: theme.primary.withValues(alpha: 0.16), - border: theme.primary.withValues(alpha: 0.32), - child: _BadgeLabel(text: 'EDIT', color: theme.primary), - ); - case CloudBadgeKind.viewer: - // Shared read-only: muted, no command color. - return _BadgePill( - background: theme.muted, - border: theme.border, - child: _BadgeLabel(text: 'VIEW', color: theme.mutedForeground), - ); - } + return Tooltip( + message: tooltip, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.background.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: theme.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: theme.foreground), + const SizedBox(width: 5), + Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: theme.foreground, + ), + ), + ], + ), + ), + ); } } -class _BadgePill extends StatelessWidget { - const _BadgePill({ - required this.background, - required this.border, - required this.child, - }); - - final Color background; - final Color border; - final Widget child; +/// "On this device": the strategy exists only in the local library. +class DeviceOnlyBadge extends StatelessWidget { + const DeviceOnlyBadge({super.key}); @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), - decoration: BoxDecoration( - color: background, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: border), - ), - child: child, + return const StrategyBadge( + icon: LucideIcons.monitor, + label: 'On this device', + tooltip: 'Saved only on this computer', ); } } -class _BadgeLabel extends StatelessWidget { - const _BadgeLabel({required this.text, required this.color}); +/// The share role of a strategy someone else owns. Null for owned strategies. +class CloudRoleBadge extends StatelessWidget { + const CloudRoleBadge({super.key, required this.kind}); - final String text; - final Color color; + final CloudBadgeKind kind; @override Widget build(BuildContext context) { - return Text( - text, - style: TextStyle( - color: color, - fontSize: 10, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - height: 1.2, - ), - ); + switch (kind) { + case CloudBadgeKind.owned: + return const SizedBox.shrink(); + case CloudBadgeKind.editor: + return const StrategyBadge( + icon: LucideIcons.users, + label: 'Shared · Can edit', + tooltip: 'Shared with you. You can edit it.', + ); + case CloudBadgeKind.viewer: + return const StrategyBadge( + icon: LucideIcons.users, + label: 'Shared · View only', + tooltip: 'Shared with you. Ask the owner for edit access.', + ); + } } } diff --git a/lib/widgets/save_and_load_button.dart b/lib/widgets/save_and_load_button.dart deleted file mode 100644 index 56d00c40..00000000 --- a/lib/widgets/save_and_load_button.dart +++ /dev/null @@ -1,289 +0,0 @@ -import 'dart:io'; - -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:hive_ce/hive.dart'; -import 'package:icarus/const/coordinate_system.dart'; -import 'package:icarus/const/hive_boxes.dart'; -import 'package:icarus/const/settings.dart'; -import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; -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_page_session_provider.dart'; -import 'package:icarus/providers/strategy_provider.dart'; -import 'package:icarus/services/cloud_strategy_export.dart'; -import 'package:icarus/strategy/strategy_import_export.dart'; -import 'package:icarus/strategy/strategy_models.dart'; -import 'package:icarus/strategy/strategy_page_models.dart'; -import 'package:icarus/screenshot/offscreen_capture.dart'; -import 'package:icarus/screenshot/screenshot_view.dart'; -import 'package:icarus/widgets/dialogs/export_video_dialog.dart'; -import 'package:icarus/widgets/settings_tab.dart'; -import 'package:icarus/widgets/cloud_sync_status_chip.dart'; -import 'package:icarus/widgets/strategy_save_icon_button.dart'; -import 'package:screenshot/screenshot.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; - -class SaveAndLoadButton extends ConsumerStatefulWidget { - const SaveAndLoadButton({super.key}); - - @override - ConsumerState createState() => - _SaveAndLoadButtonState(); -} - -class _SaveAndLoadButtonState extends ConsumerState { - bool _isLoading = false; - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - ShadTooltip( - builder: (context) => const Text("Settings"), - child: ShadIconButton.ghost( - foregroundColor: Colors.white, - onPressed: () async { - showShadDialog( - context: context, - builder: (context) => const SettingsTab(), - ); - }, - icon: const Icon(Icons.settings), - ), - ), - const AutoSaveButton(), - const CloudSyncStatusChip(), - ShadTooltip( - builder: (context) => const Text("Export"), - child: ShadIconButton.ghost( - foregroundColor: Colors.white, - onPressed: () async { - if (kIsWeb) { - Settings.showToast( - message: - 'This feature is only supported in the desktop app.', - backgroundColor: Settings.tacticalVioletTheme.destructive, - ); - return; - } - - final strategy = ref.read(strategyProvider); - final strategyId = strategy.strategyId; - if (strategyId == null || strategy.source == null) { - throw StateError('No strategy is open for export.'); - } - - final exporter = StrategyImportExportService(ref); - switch (strategy.source!) { - case StrategySource.cloud: - await runCloudStrategyExport(ref, strategyId); - case StrategySource.local: - await exporter.exportFile(strategyId); - } - }, - icon: const Icon(Icons.file_upload), - ), - ), - ShadTooltip( - builder: (context) => const Text("Export Video"), - child: ShadIconButton.ghost( - foregroundColor: Colors.white, - onPressed: () async { - if (kIsWeb) { - Settings.showToast( - message: - 'This feature is only supported in the Windows version.', - backgroundColor: Settings.tacticalVioletTheme.destructive, - ); - return; - } - showShadDialog( - context: context, - builder: (context) => const ExportVideoDialog(), - ); - }, - icon: const Icon(Icons.movie_outlined), - ), - ), - ShadTooltip( - builder: (context) => const Text("Screenshot"), - child: ShadIconButton.ghost( - foregroundColor: Colors.white, - onPressed: () async { - if (kIsWeb) { - Settings.showToast( - message: - 'This feature is only supported in the desktop app.', - backgroundColor: Settings.tacticalVioletTheme.destructive, - ); - return; - } - if (_isLoading) return; - setState(() { - _isLoading = true; - }); - CoordinateSystem.instance.setIsScreenshot(true); - - final String id = ref.read(strategyProvider).strategyId!; - - await ref.read(strategyProvider.notifier).forceSaveNow(id); - - final newStrat = - Hive.box(HiveBoxNames.strategiesBox) - .values - .where((StrategyData strategy) { - return strategy.id == id; - }).firstOrNull; - - if (newStrat == null) { - return; - } - final newController = ScreenshotController(); - final mapState = ref.read(mapProvider); - final currentPageID = - ref.read(strategyPageSessionProvider).activePageId; - - if (currentPageID == null) return; - - final activePage = newStrat.pages.firstWhere( - (p) => p.id == currentPageID, - orElse: () => newStrat.pages.first, - ); - final screenshotContainer = ProviderContainer(); - - try { - final screenshotView = ScreenshotView( - isAttack: activePage.isAttack, - mapValue: newStrat.mapData, - showSpawnBarrier: mapState.showSpawnBarrier, - showRegionNames: mapState.showRegionNames, - showUltOrbs: mapState.showUltOrbs, - agents: activePage.agentData, - abilities: activePage.abilityData, - text: activePage.textData, - images: activePage.imageData, - drawings: activePage.drawingData, - utilities: activePage.utilityData, - strategySettings: activePage.settings, - strategyState: ref.read(strategyProvider), - pageName: activePage.name, - lineUpGroups: activePage.lineUpGroups, - themeProfileId: newStrat.themeProfileId, - themeOverridePalette: newStrat.themeOverridePalette, - ); - screenshotView.hydrateProviders(screenshotContainer); - final image = await newController.captureFromWidget( - targetSize: CoordinateSystem.screenShotSize, - wrapForOffscreenCapture( - screenshotView, - container: screenshotContainer, - ), - ); - setState(() { - _isLoading = false; - }); - String? outputFile = await FilePicker.platform.saveFile( - type: FileType.custom, - dialogTitle: 'Please select an output file:', - fileName: - "${ref.read(strategyProvider).strategyName ?? "new image"}.png", - allowedExtensions: ['png'], - ); - if (outputFile != null) { - final file = File(outputFile); - await file.writeAsBytes(image); - } - } catch (_) { - } finally { - screenshotContainer.dispose(); - ref.read(screenshotProvider.notifier).setIsScreenShot(false); - CoordinateSystem.instance.setIsScreenshot(false); - ref - .read(drawingProvider.notifier) - .rebuildAllPaths(CoordinateSystem.instance); - } - // CoordinateSystem.instance.setIsScreenshot(false); - }, - icon: _isLoading - ? const SizedBox( - height: 18, - width: 18, - child: CircularProgressIndicator( - color: Colors.white, - ), - ) - : const Icon(Icons.camera_alt_outlined), - ), - ), - if (_isViewOnly()) ...[ - const SizedBox(width: 4), - const _ViewOnlyChip(), - ], - ], - ), - ); - } - - bool _isViewOnly() { - final source = ref.watch(strategyProvider.select((value) => value.source)); - if (source != StrategySource.cloud) { - return false; - } - // Read the cached role rather than the raw snapshot so the chip does not - // flicker off while the snapshot is reloading or transiently errored; it - // is absent only before the role has ever been known. - final role = ref.watch(lastKnownCloudRoleProvider); - return role == 'viewer'; - } -} - -/// Non-interactive chip shown in the editor top strip when the open cloud -/// strategy is shared with view-only access. -class _ViewOnlyChip extends StatelessWidget { - const _ViewOnlyChip(); - - @override - Widget build(BuildContext context) { - const theme = Settings.tacticalVioletTheme; - - return ShadTooltip( - builder: (context) => const Text( - 'You have view access. Ask the owner for edit access to make changes.', - ), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: theme.muted, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: theme.border), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.visibility_outlined, - size: 14, - color: theme.mutedForeground, - ), - const SizedBox(width: 6), - Text( - 'View only', - style: TextStyle( - color: theme.mutedForeground, - fontSize: 12, - fontWeight: FontWeight.w600, - letterSpacing: 0.3, - height: 1.2, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/settings_tab.dart b/lib/widgets/settings_tab.dart index dd36c40c..3e3710f2 100644 --- a/lib/widgets/settings_tab.dart +++ b/lib/widgets/settings_tab.dart @@ -205,7 +205,7 @@ class _StrategySettingsSections extends ConsumerWidget { child: Column( children: [ _SettingsSliderTile( - icon: Icons.person_pin_circle_outlined, + icon: LucideIcons.personStanding, iconColor: Settings.settingsAgentAccent, title: "Agent markers", description: @@ -233,7 +233,7 @@ class _StrategySettingsSections extends ConsumerWidget { }, ), _SettingsSliderTile( - icon: Icons.auto_awesome_outlined, + icon: LucideIcons.sparkles, iconColor: Settings.settingsAbilityAccent, title: "Ability markers", description: @@ -261,7 +261,7 @@ class _StrategySettingsSections extends ConsumerWidget { }, ), _SettingsToggleTile( - icon: Icons.contrast_outlined, + icon: LucideIcons.contrast, iconColor: Settings.settingsNeutralAccent, title: "Neutral team marker colors", description: @@ -323,7 +323,7 @@ class _GlobalSettingsSections extends ConsumerWidget { child: Column( children: [ _SettingsSliderTile( - icon: Icons.person_pin_circle_outlined, + icon: LucideIcons.personStanding, iconColor: Settings.settingsAgentAccent, title: "Default agent markers", description: @@ -340,7 +340,7 @@ class _GlobalSettingsSections extends ConsumerWidget { }, ), _SettingsSliderTile( - icon: Icons.auto_awesome_outlined, + icon: LucideIcons.sparkles, iconColor: Settings.settingsAbilityAccent, title: "Default ability markers", description: @@ -357,7 +357,7 @@ class _GlobalSettingsSections extends ConsumerWidget { }, ), _SettingsToggleTile( - icon: Icons.contrast_outlined, + icon: LucideIcons.contrast, iconColor: Settings.settingsNeutralAccent, title: "Neutral marker colors by default", description: @@ -382,7 +382,7 @@ class _GlobalSettingsSections extends ConsumerWidget { child: Column( children: [ _SettingsToggleTile( - icon: Icons.save_outlined, + icon: LucideIcons.save, iconColor: Settings.settingsPersistenceAccent, title: "Autosave", description: @@ -398,7 +398,7 @@ class _GlobalSettingsSections extends ConsumerWidget { }, ), _SettingsToggleTile( - icon: Icons.sports_esports_outlined, + icon: LucideIcons.gamepad2, iconColor: Settings.settingsDiscordAccent, title: "Discord Rich Presence", description: @@ -420,7 +420,7 @@ class _GlobalSettingsSections extends ConsumerWidget { child: Column( children: [ _SettingsToggleTile( - icon: Icons.grid_on_rounded, + icon: LucideIcons.grid3x3, iconColor: Settings.settingsMapAccent, title: "Spawn barriers", description: @@ -431,7 +431,7 @@ class _GlobalSettingsSections extends ConsumerWidget { }, ), _SettingsToggleTile( - icon: Icons.location_on_outlined, + icon: LucideIcons.mapPin, iconColor: Settings.settingsMapAccent, title: "Region names", description: "Show map callout names directly on the canvas.", @@ -441,7 +441,7 @@ class _GlobalSettingsSections extends ConsumerWidget { }, ), _SettingsToggleTile( - icon: Icons.radio_button_checked_outlined, + icon: LucideIcons.circleDot, iconColor: Settings.settingsMapAccent, title: "Ultimate orbs", description: "Display orb pickup markers on supported maps.", @@ -458,7 +458,7 @@ class _GlobalSettingsSections extends ConsumerWidget { key: sectionKeys[_SettingsSection.globalPrivacy], title: "Privacy", child: _SettingsToggleTile( - icon: Icons.analytics_outlined, + icon: LucideIcons.chartColumn, iconColor: Settings.settingsPersistenceAccent, title: "Anonymous analytics", description: @@ -530,7 +530,7 @@ class _ShortcutSettingsSectionState const SizedBox(width: 12), ShadButton.secondary( size: ShadButtonSize.sm, - leading: const Icon(Icons.restart_alt_outlined, size: 15), + leading: const Icon(LucideIcons.rotateCcw, size: 15), onPressed: customBindings.isEmpty ? null : () { @@ -650,7 +650,7 @@ class _ShortcutSearchField extends StatelessWidget { fontSize: 13, ), prefixIcon: Icon( - Icons.search, + LucideIcons.search, size: 17, color: Settings.tacticalVioletTheme.mutedForeground, ), @@ -770,7 +770,7 @@ class _ShortcutBindingRow extends StatelessWidget { ShadTooltip( builder: (_) => const Text("Reset to default"), child: ShadIconButton.ghost( - icon: const Icon(Icons.undo_outlined, size: 15), + icon: const Icon(LucideIcons.undo2, size: 15), onPressed: onReset, ), ) @@ -959,7 +959,7 @@ class _ShortcutCaptureFieldState extends State<_ShortcutCaptureField> child: Row( children: [ Icon( - Icons.keyboard_alt_outlined, + LucideIcons.keyboard, size: 17, color: hasDuplicate ? Settings.tacticalVioletTheme.destructive @@ -1058,7 +1058,7 @@ class AccountSettingsSection extends ConsumerWidget { child: Row( children: [ Icon( - Icons.cloud_off_outlined, + LucideIcons.cloudOff, size: 18, color: Settings.tacticalVioletTheme.mutedForeground, ), @@ -1106,22 +1106,22 @@ class _SignedInAccountRow extends ConsumerWidget { switch (authState.convexAuthStatus) { ConvexAuthStatus.ready => ( 'Cloud sync active', - Icons.cloud_done_outlined, + LucideIcons.cloudCheck, theme.mutedForeground, ), ConvexAuthStatus.configuring => ( 'Connecting to cloud…', - Icons.cloud_sync_outlined, + LucideIcons.refreshCw, theme.mutedForeground, ), ConvexAuthStatus.incident => ( 'Cloud connection needs attention', - Icons.error_outline, + LucideIcons.circleAlert, theme.destructive, ), ConvexAuthStatus.signedOut => ( 'Cloud sync inactive', - Icons.cloud_off_outlined, + LucideIcons.cloudOff, theme.mutedForeground, ), }; @@ -1240,13 +1240,13 @@ class _SettingsNavigationRail extends StatelessWidget { const _SettingsNavHeader(label: "Current strategy"), const SizedBox(height: 4), _SettingsNavItem( - icon: Icons.tune_outlined, + icon: LucideIcons.slidersHorizontal, label: "Object styling", isSelected: selectedSection == _SettingsSection.strategyObjects, onTap: () => onSectionSelected(_SettingsSection.strategyObjects), ), _SettingsNavItem( - icon: Icons.palette_outlined, + icon: LucideIcons.palette, label: "Map theme", isSelected: selectedSection == _SettingsSection.strategyMapTheme, onTap: () => onSectionSelected(_SettingsSection.strategyMapTheme), @@ -1255,38 +1255,38 @@ class _SettingsNavigationRail extends StatelessWidget { const _SettingsNavHeader(label: "App-wide"), const SizedBox(height: 4), _SettingsNavItem( - icon: Icons.person_outline, + icon: LucideIcons.user, label: "Account", isSelected: selectedSection == _SettingsSection.globalAccount, onTap: () => onSectionSelected(_SettingsSection.globalAccount), ), _SettingsNavItem( - icon: Icons.auto_fix_high_outlined, + icon: LucideIcons.wandSparkles, label: "Defaults", isSelected: selectedSection == _SettingsSection.globalDefaults, onTap: () => onSectionSelected(_SettingsSection.globalDefaults), ), _SettingsNavItem( - icon: Icons.save_outlined, + icon: LucideIcons.save, label: "Autosave", isSelected: selectedSection == _SettingsSection.globalSaving, onTap: () => onSectionSelected(_SettingsSection.globalSaving), ), _SettingsNavItem( - icon: Icons.map_outlined, + icon: LucideIcons.map, label: "Map layers", isSelected: selectedSection == _SettingsSection.globalMapVisibility, onTap: () => onSectionSelected(_SettingsSection.globalMapVisibility), ), _SettingsNavItem( - icon: Icons.privacy_tip_outlined, + icon: LucideIcons.shield, label: "Privacy", isSelected: selectedSection == _SettingsSection.globalPrivacy, onTap: () => onSectionSelected(_SettingsSection.globalPrivacy), ), _SettingsNavItem( - icon: Icons.keyboard_alt_outlined, + icon: LucideIcons.keyboard, label: "Keybinds", isSelected: selectedSection == _SettingsSection.shortcuts, onTap: () => onSectionSelected(_SettingsSection.shortcuts), diff --git a/lib/widgets/sidebar_widgets/agent_dragable.dart b/lib/widgets/sidebar_widgets/agent_dragable.dart index 5e2861d4..e75ee4c2 100644 --- a/lib/widgets/sidebar_widgets/agent_dragable.dart +++ b/lib/widgets/sidebar_widgets/agent_dragable.dart @@ -144,8 +144,8 @@ class _AgentDragableState extends ConsumerState DateTime.now().isAfter(_starOffEnabledAt); final iconData = canShowStarOff ? LucideIcons.starOff - : (isFavorite ? Icons.star_rounded : LucideIcons.star); - final iconSize = iconData == Icons.star_rounded ? 18.5 : 16.0; + : (isFavorite ? LucideIcons.star600 : LucideIcons.star); + const iconSize = 16.0; final iconColor = isFavorite ? (canShowStarOff ? const Color(0xFFE53935) : const Color(0xFFFF9800)) : (_isStarHovered ? const Color(0xFFFF9800) : const Color(0xFF9AA0A6)); diff --git a/lib/widgets/sidebar_widgets/custom_shape_tools.dart b/lib/widgets/sidebar_widgets/custom_shape_tools.dart index abac7181..0d2f4a94 100644 --- a/lib/widgets/sidebar_widgets/custom_shape_tools.dart +++ b/lib/widgets/sidebar_widgets/custom_shape_tools.dart @@ -94,12 +94,12 @@ class _CustomShapeToolsState extends ConsumerState { spacing: 4, children: [ SelectableIconButton( - icon: const Icon(Icons.circle_outlined, size: 20), + icon: const Icon(LucideIcons.circle, size: 20), isSelected: _shape == _CustomShapeKind.circle, onPressed: () => setState(() => _shape = _CustomShapeKind.circle), ), SelectableIconButton( - icon: const Icon(Icons.crop_square, size: 20), + icon: const Icon(LucideIcons.square, size: 20), isSelected: _shape == _CustomShapeKind.rectangle, onPressed: () => setState(() => _shape = _CustomShapeKind.rectangle), @@ -178,8 +178,8 @@ class _CustomShapeToolsState extends ConsumerState { onChanged: (v) => setState(() => _opacityPercent = v.round()), min: 5, max: 80, - leading: Icon(Icons.opacity, - color: Settings.tacticalVioletTheme.mutedForeground), + leading: Icon(LucideIcons.droplet, + size: 16, color: Settings.tacticalVioletTheme.mutedForeground), // label: 'O', hintText: 'Enter Opacity', suffix: '%', diff --git a/lib/widgets/sidebar_widgets/delete_options.dart b/lib/widgets/sidebar_widgets/delete_options.dart index 779f5513..bc737b60 100644 --- a/lib/widgets/sidebar_widgets/delete_options.dart +++ b/lib/widgets/sidebar_widgets/delete_options.dart @@ -25,32 +25,32 @@ class DeleteOptions extends ConsumerWidget { static const List<_DeleteOptionData> _options = [ _DeleteOptionData( group: ActionGroup.agent, - icon: Icons.person, + icon: LucideIcons.user, label: 'Agents', ), _DeleteOptionData( group: ActionGroup.ability, - icon: Icons.bolt, + icon: LucideIcons.zap, label: 'Abilities', ), _DeleteOptionData( group: ActionGroup.drawing, - icon: Icons.draw, + icon: LucideIcons.pencil, label: 'Drawings', ), _DeleteOptionData( group: ActionGroup.text, - icon: Icons.text_fields, + icon: LucideIcons.type, label: 'Text', ), _DeleteOptionData( group: ActionGroup.image, - icon: Icons.image, + icon: LucideIcons.image, label: 'Images', ), _DeleteOptionData( group: ActionGroup.utility, - icon: Icons.crop_square, + icon: LucideIcons.square, label: 'Utilities', ), ]; diff --git a/lib/widgets/sidebar_widgets/drawing_tools.dart b/lib/widgets/sidebar_widgets/drawing_tools.dart index 9c047bb3..9b91203c 100644 --- a/lib/widgets/sidebar_widgets/drawing_tools.dart +++ b/lib/widgets/sidebar_widgets/drawing_tools.dart @@ -98,7 +98,7 @@ class DrawingTools extends ConsumerWidget { ), SelectableIconButton( icon: const Icon( - Icons.crop_square, + LucideIcons.square, size: 20, ), isSelected: penMode == PenMode.square, @@ -111,7 +111,7 @@ class DrawingTools extends ConsumerWidget { ), SelectableIconButton( icon: const Icon( - Icons.circle_outlined, + LucideIcons.circle, size: 20, ), isSelected: penMode == PenMode.ellipse, diff --git a/lib/widgets/sidebar_widgets/tool_grid.dart b/lib/widgets/sidebar_widgets/tool_grid.dart index aee8fc59..b89c997a 100644 --- a/lib/widgets/sidebar_widgets/tool_grid.dart +++ b/lib/widgets/sidebar_widgets/tool_grid.dart @@ -131,7 +131,7 @@ class ToolGrid extends ConsumerWidget { crossAxisSpacing: 5, children: [ SelectableIconButton( - icon: const Icon(Icons.draw), + icon: const Icon(LucideIcons.pencil, size: 22), tooltip: "Draw", shortcutLabel: shortcutLabel(IcarusShortcutAction.draw), onPressed: () { @@ -166,7 +166,7 @@ class ToolGrid extends ConsumerWidget { }, icon: const Icon( CustomIcons.eraser, - size: 20, + size: 22, ), isSelected: currentInteractionState == InteractionState.erasing, ), @@ -185,7 +185,7 @@ class ToolGrid extends ConsumerWidget { .update(InteractionState.textTools); } }, - icon: const Icon(Icons.text_fields), + icon: const Icon(LucideIcons.type, size: 22), isSelected: currentInteractionState == InteractionState.textTools, ), @@ -248,7 +248,7 @@ class ToolGrid extends ConsumerWidget { tagColorValue: imageResult.tagColorValue, ); }, - icon: const Icon(Icons.image_outlined), + icon: const Icon(LucideIcons.image, size: 22), ), ), SelectableIconButton( @@ -275,7 +275,7 @@ class ToolGrid extends ConsumerWidget { .update(InteractionState.lineUpPlacing); } }, - icon: const Icon(LucideIcons.bookOpen400), + icon: const Icon(LucideIcons.bookOpen400, size: 22), isSelected: ref.watch(interactionStateProvider) == InteractionState.lineUpPlacing, ), @@ -293,7 +293,7 @@ class ToolGrid extends ConsumerWidget { .update(InteractionState.visionCone); } }, - icon: const Icon(LucideIcons.eye, size: 20), + icon: const Icon(LucideIcons.eye, size: 22), isSelected: currentInteractionState == InteractionState.visionCone, ), @@ -311,7 +311,7 @@ class ToolGrid extends ConsumerWidget { .update(InteractionState.customShapes); } }, - icon: const Icon(Icons.crop_square, size: 20), + icon: const Icon(LucideIcons.square, size: 22), isSelected: currentInteractionState == InteractionState.customShapes, ), diff --git a/lib/widgets/strategy_quick_switcher.dart b/lib/widgets/strategy_quick_switcher.dart index b336f824..79f1f3bd 100644 --- a/lib/widgets/strategy_quick_switcher.dart +++ b/lib/widgets/strategy_quick_switcher.dart @@ -488,8 +488,8 @@ class _StrategyQuickSwitcherState extends ConsumerState { ) : Icon( _isOpen - ? Icons.keyboard_arrow_up - : Icons.keyboard_arrow_down, + ? LucideIcons.chevronUp + : LucideIcons.chevronDown, color: Colors.white, size: 18, ), diff --git a/lib/widgets/strategy_save_icon_button.dart b/lib/widgets/strategy_save_icon_button.dart index 4d84cac0..511e12bf 100644 --- a/lib/widgets/strategy_save_icon_button.dart +++ b/lib/widgets/strategy_save_icon_button.dart @@ -7,81 +7,99 @@ import 'package:icarus/providers/auto_save_notifier.dart'; import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/strategy_save_state_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/widgets/editor_toolbar.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:toastification/toastification.dart'; -/// The save button that animates on auto-save pings. +/// Saves the open strategy immediately and tells the user what landed. +Future saveStrategyNow(BuildContext context, WidgetRef ref) async { + final strategyId = ref.read(strategyProvider).strategyId; + if (strategyId == null) return; + await ref.read(strategyProvider.notifier).forceSaveNow(strategyId); + if (!context.mounted) return; + + final latestSaveState = ref.read(strategySaveStateProvider); + final hasIncompleteMediaSync = latestSaveState.hasPendingMediaSync || + latestSaveState.mediaSyncErrorCount > 0; + final toastMessage = hasIncompleteMediaSync + ? latestSaveState.mediaSyncErrorCount > 0 + ? 'Saved on this device. Media sync needs retry.' + : 'Saved on this device. Media still syncing.' + : 'Saved'; + + toastification.showCustom( + context: context, + autoCloseDuration: const Duration(seconds: 3), + alignment: Alignment.bottomCenter, + builder: (context, holder) { + return Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.card, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Settings.tacticalVioletTheme.border), + ), + child: Text( + toastMessage, + style: ShadTheme.of(context) + .textTheme + .small + .copyWith(color: Settings.tacticalVioletTheme.foreground), + ), + ); + }, + ); +} + +/// The save button of a local strategy. Shows a spinner while an auto-save +/// runs and a check when it lands, then rests on the save glyph. class AutoSaveButton extends ConsumerStatefulWidget { - const AutoSaveButton({super.key}); + const AutoSaveButton({ + super.key, + this.style = kEditorToolbarButtonStyle, + }); + + final EditorToolbarButtonStyle style; @override ConsumerState createState() => _AutoSaveButtonState(); } -class _AutoSaveButtonState extends ConsumerState - with SingleTickerProviderStateMixin { - /// Listen to the autoSave ping counter. - // late final AutoDisposeProviderSubscription _sub; - - /// Drives continuous rotation in the loading phase. - late final AnimationController _rotationController; - - /// Our own internal phase. +class _AutoSaveButtonState extends ConsumerState { _Phase _phase = _Phase.idle; + Timer? _successTimer; + Timer? _idleTimer; + int _lastPing = 0; @override void initState() { super.initState(); _lastPing = ref.read(autoSaveProvider); - _rotationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 800), - )..repeat(); // we'll stop when not loading } @override void dispose() { - _rotationController.dispose(); + _successTimer?.cancel(); + _idleTimer?.cancel(); super.dispose(); } void _startAutoSaveAnimation() { if (!mounted) return; - + _successTimer?.cancel(); + _idleTimer?.cancel(); setState(() => _phase = _Phase.loading); - _rotationController.repeat(); - - // After 3s, show check and snackbar - Timer(const Duration(seconds: 3), () { + _successTimer = Timer(const Duration(seconds: 3), () { if (!mounted) return; - _rotationController.stop(); setState(() => _phase = _Phase.success); - - // // show the snack bar here, outside build - // ScaffoldMessenger.of(context).showSnackBar( - // const SnackBar( - // content: Center( - // child: Text( - // "Auto‐save complete", - // style: TextStyle(color: Colors.white), - // ), - // ), - // duration: Duration(seconds: 2), - // backgroundColor: Settings.sideBarColor, - // behavior: SnackBarBehavior.floating, - // width: 200, - // ), - // ); - - // after 1s go back to idle - Timer(const Duration(seconds: 1), () { + _idleTimer = Timer(const Duration(seconds: 1), () { if (!mounted) return; setState(() => _phase = _Phase.idle); }); }); } - int _lastPing = 0; @override Widget build(BuildContext context) { final ping = ref.watch(autoSaveProvider); @@ -95,80 +113,39 @@ class _AutoSaveButtonState extends ConsumerState _lastPing = ping; _startAutoSaveAnimation(); } - Widget icon; - switch (_phase) { - case _Phase.idle: - _rotationController.stop(); - icon = const Icon(Icons.save); - break; - - case _Phase.loading: - _rotationController.stop(); - icon = const SizedBox( - width: 24, - height: 24, + + final size = widget.style.iconSize; + final Widget icon = switch (_phase) { + _Phase.idle => Icon(LucideIcons.save, key: const ValueKey('idle')), + _Phase.loading => SizedBox( + key: const ValueKey('loading'), + width: size - 2, + height: size - 2, child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), + strokeWidth: 1.8, + valueColor: AlwaysStoppedAnimation( + Settings.tacticalVioletTheme.mutedForeground, + ), ), - ); - break; - - case _Phase.success: - _rotationController.stop(); - icon = const Icon(Icons.check, color: Colors.greenAccent); - - break; - } - - return ShadTooltip( - builder: (context) => Text(canEditPages ? "Save" : "View only"), - child: ShadIconButton.ghost( - foregroundColor: Colors.white, - icon: icon, - enabled: canEditPages, - onPressed: () async { - await ref - .read(strategyProvider.notifier) - .forceSaveNow(ref.read(strategyProvider).strategyId!); - if (!context.mounted) return; - - final latestSaveState = ref.read(strategySaveStateProvider); - final hasIncompleteMediaSync = latestSaveState.hasPendingMediaSync || - latestSaveState.mediaSyncErrorCount > 0; - final toastMessage = hasIncompleteMediaSync - ? latestSaveState.mediaSyncErrorCount > 0 - ? 'Local save complete. Media sync needs retry.' - : 'Local save complete. Media still syncing.' - : 'Save Complete'; - - toastification.showCustom( - context: context, - autoCloseDuration: const Duration(seconds: 3), - alignment: Alignment.bottomCenter, - builder: (context, holder) { - return Container( - margin: const EdgeInsets.all(16), - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.card, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - ), - ), - child: Text( - toastMessage, - style: ShadTheme.of(context) - .textTheme - .small - .copyWith(color: Colors.white), - ), - ); - }, - ); - }, + ), + _Phase.success => Icon( + LucideIcons.check, + key: const ValueKey('success'), + color: Settings.allyBGColor, + ), + }; + + return EditorToolbarButton( + key: const ValueKey('local-save-button'), + style: widget.style, + tooltip: canEditPages ? 'Save' : 'View only', + enabled: canEditPages, + onPressed: () => saveStrategyNow(context, ref), + icon: AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: icon, ), ); } diff --git a/lib/widgets/strategy_tile/strategy_tile.dart b/lib/widgets/strategy_tile/strategy_tile.dart index 7d7d143d..bf9fa8e9 100644 --- a/lib/widgets/strategy_tile/strategy_tile.dart +++ b/lib/widgets/strategy_tile/strategy_tile.dart @@ -18,6 +18,7 @@ import 'package:icarus/widgets/dialogs/strategy/rename_strategy_dialog.dart'; import 'package:icarus/widgets/drag_tilt_feedback.dart'; import 'package:icarus/widgets/drop_insertion_indicator.dart'; import 'package:icarus/widgets/folder_navigator.dart'; +import 'package:icarus/widgets/role_badge.dart'; import 'package:icarus/widgets/strategy_tile/strategy_tile_sections.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -89,7 +90,7 @@ class StrategyTileActionsButton extends StatelessWidget { width: 28, height: 28, onPressed: onPressed, - icon: const Icon(Icons.more_vert_outlined), + icon: const Icon(LucideIcons.ellipsisVertical), ), ); } @@ -311,7 +312,7 @@ class _StrategyTileState extends ConsumerState { borderRadius: strategyTileInnerRadius, overlay: widget.showDeviceBadge ? const DeviceOnlyBadge() - : null, + : viewData.sharedBadge, ), ), const SizedBox(height: 10), @@ -342,7 +343,7 @@ class _StrategyTileState extends ConsumerState { ), child: const Padding( padding: EdgeInsets.all(5), - child: Icon(Icons.push_pin, size: 15), + child: Icon(LucideIcons.pin, size: 15), ), ), ), @@ -364,7 +365,7 @@ class _StrategyTileState extends ConsumerState { height: 28, onPressed: _handleMenuButtonPressed, icon: - const Icon(Icons.more_vert_outlined), + const Icon(LucideIcons.ellipsisVertical), ), ), ), @@ -403,7 +404,7 @@ class _StrategyTileState extends ConsumerState { final isPinned = pinned.containsKey(id); return [ ShadContextMenuItem( - leading: Icon(isPinned ? Icons.push_pin : Icons.push_pin_outlined), + leading: Icon(isPinned ? LucideIcons.pinOff : LucideIcons.pin), child: Text(isPinned ? 'Unpin' : 'Pin'), onPressed: () { _closeMenus(); @@ -450,8 +451,11 @@ class _StrategyTileState extends ConsumerState { }, ), ShadContextMenuItem( - leading: const Icon(LucideIcons.trash2, color: Colors.redAccent), - child: const Text('Delete', style: TextStyle(color: Colors.redAccent)), + leading: Icon(LucideIcons.trash2, + color: Settings.tacticalVioletTheme.destructive), + child: Text('Delete', + style: + TextStyle(color: Settings.tacticalVioletTheme.destructive)), enabled: widget.canDelete, onPressed: widget.canDelete ? () { diff --git a/lib/widgets/strategy_tile/strategy_tile_sections.dart b/lib/widgets/strategy_tile/strategy_tile_sections.dart index 4d934ba8..f15e19ef 100644 --- a/lib/widgets/strategy_tile/strategy_tile_sections.dart +++ b/lib/widgets/strategy_tile/strategy_tile_sections.dart @@ -68,10 +68,17 @@ class StrategyTileViewData { final String lastEditedLabel; final List agentTypes; - /// Non-null only for cloud strategies. Local tiles leave this null so they - /// render exactly as before. + /// Non-null only for cloud strategies. Owned strategies render no badge; + /// shared ones show their role over the thumbnail. final CloudBadgeKind? cloudBadge; + /// The pill drawn over the thumbnail, if this strategy needs one. + Widget? get sharedBadge { + final kind = cloudBadge; + if (kind == null || kind == CloudBadgeKind.owned) return null; + return CloudRoleBadge(kind: kind); + } + static String _mapName(MapValue? map) { final raw = map == null ? null : Maps.mapNames[map]; if (raw == null || raw.isEmpty) { @@ -187,45 +194,6 @@ class StrategyTileThumbnail extends StatelessWidget { } } -/// "On this device": the strategy exists only in the local library. -class DeviceOnlyBadge extends StatelessWidget { - const DeviceOnlyBadge({super.key}); - - @override - Widget build(BuildContext context) { - return Tooltip( - message: 'Saved only on this computer', - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.background.withValues(alpha: 0.85), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: Settings.tacticalVioletTheme.border), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - LucideIcons.monitor, - size: 12, - color: Settings.tacticalVioletTheme.foreground, - ), - const SizedBox(width: 5), - Text( - 'On this device', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Settings.tacticalVioletTheme.foreground, - ), - ), - ], - ), - ), - ); - } -} - class StrategyTileDetails extends StatelessWidget { const StrategyTileDetails({ super.key, @@ -271,10 +239,6 @@ class StrategyTileDetails extends StatelessWidget { ), ), ), - if (data.cloudBadge != null) ...[ - const SizedBox(width: 6), - CloudRoleBadge(kind: data.cloudBadge!), - ], ], ), const SizedBox(height: 5), @@ -418,7 +382,7 @@ class _MoreAgentsIndicator extends StatelessWidget { border: Border.all(color: Settings.tacticalVioletTheme.border), ), child: const Icon( - Icons.more_horiz, + LucideIcons.ellipsis, color: Color.fromARGB(190, 210, 214, 219), size: 18, ), diff --git a/lib/widgets/strategy_view_skeleton.dart b/lib/widgets/strategy_view_skeleton.dart index 70a79428..74c6b61f 100644 --- a/lib/widgets/strategy_view_skeleton.dart +++ b/lib/widgets/strategy_view_skeleton.dart @@ -275,7 +275,7 @@ class _MapSelectorSkeleton extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - isAttack ? CustomIcons.sword : Icons.shield, + isAttack ? CustomIcons.sword : LucideIcons.shield, size: 20, color: Settings.tacticalVioletTheme.mutedForeground, ), @@ -387,16 +387,25 @@ class _FloatingControlSkeleton extends StatelessWidget { @override Widget build(BuildContext context) { - return const Row( - children: [ - _SkeletonBlock(width: 40, height: 40, radius: 8), - SizedBox(width: 8), - _SkeletonBlock(width: 40, height: 40, radius: 8), - SizedBox(width: 8), - _SkeletonBlock(width: 40, height: 40, radius: 8), - SizedBox(width: 8), - _SkeletonBlock(width: 40, height: 40, radius: 8), - ], + // Mirrors EditorToolbar: five 32px controls in one 12px card. + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: _tone(Settings.tacticalVioletTheme.card, 0.9), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _tone(Settings.highlightColor, 0.88)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + _SkeletonBlock(width: 32, height: 32, radius: 8), + _SkeletonBlock(width: 32, height: 32, radius: 8), + _SkeletonBlock(width: 32, height: 32, radius: 8), + _SkeletonBlock(width: 32, height: 32, radius: 8), + SizedBox(width: 9), + _SkeletonBlock(width: 32, height: 32, radius: 8), + ], + ), ); } } diff --git a/lib/widgets/vision_boundary_editor.dart b/lib/widgets/vision_boundary_editor.dart index 7cf3cd6b..5d021c7a 100644 --- a/lib/widgets/vision_boundary_editor.dart +++ b/lib/widgets/vision_boundary_editor.dart @@ -203,7 +203,7 @@ class VisionBoundaryEditorHud extends ConsumerWidget { ); } }, - icon: const Icon(Icons.polyline_outlined, size: 20), + icon: const Icon(LucideIcons.spline, size: 20), ), ); } @@ -252,7 +252,7 @@ class _VisionBoundaryEditorPanel extends ConsumerWidget { children: [ Row( children: [ - const Icon(Icons.polyline_outlined, size: 18), + const Icon(LucideIcons.spline, size: 18), const SizedBox(width: 8), Expanded( child: Column( @@ -304,7 +304,7 @@ class _VisionBoundaryEditorPanel extends ConsumerWidget { ); } }, - icon: const Icon(Icons.close, size: 18), + icon: const Icon(LucideIcons.x, size: 18), ), ], ), @@ -373,14 +373,14 @@ class _VisionBoundaryEditorPanel extends ConsumerWidget { height: 32, enabled: notifier.canUndo, onPressed: notifier.canUndo ? notifier.undo : null, - icon: const Icon(Icons.undo, size: 18), + icon: const Icon(LucideIcons.undo, size: 18), ), ShadIconButton.ghost( width: 32, height: 32, enabled: notifier.canRedo, onPressed: notifier.canRedo ? notifier.redo : null, - icon: const Icon(Icons.redo, size: 18), + icon: const Icon(LucideIcons.redo, size: 18), ), ], ), @@ -406,19 +406,19 @@ class _VisionBoundaryEditorPanel extends ConsumerWidget { ), const SizedBox(width: 4), _NudgeButton( - icon: Icons.keyboard_arrow_left, + icon: LucideIcons.chevronLeft, onPressed: () => notifier.nudge(const Offset(-1, 0)), ), _NudgeButton( - icon: Icons.keyboard_arrow_up, + icon: LucideIcons.chevronUp, onPressed: () => notifier.nudge(const Offset(0, -1)), ), _NudgeButton( - icon: Icons.keyboard_arrow_down, + icon: LucideIcons.chevronDown, onPressed: () => notifier.nudge(const Offset(0, 1)), ), _NudgeButton( - icon: Icons.keyboard_arrow_right, + icon: LucideIcons.chevronRight, onPressed: () => notifier.nudge(const Offset(1, 0)), ), ], @@ -430,7 +430,7 @@ class _VisionBoundaryEditorPanel extends ConsumerWidget { enabled: editor.isDirty, onPressed: editor.isDirty ? notifier.discardChanges : null, - leading: const Icon(Icons.restart_alt, size: 16), + leading: const Icon(LucideIcons.rotateCcw, size: 16), child: const Text('Discard'), ), const SizedBox(width: 8), @@ -445,7 +445,7 @@ class _VisionBoundaryEditorPanel extends ConsumerWidget { Settings.tacticalVioletTheme.primary, ); }, - leading: const Icon(Icons.copy, size: 16), + leading: const Icon(LucideIcons.copy, size: 16), child: const Text('Copy JSON'), ), const Spacer(), @@ -491,7 +491,7 @@ class _VisionBoundaryEditorPanel extends ConsumerWidget { strokeWidth: 2, ), ) - : const Icon(Icons.save_outlined, size: 16), + : const Icon(LucideIcons.save, size: 16), child: const Text('Save asset'), ), ], diff --git a/test/custom_shape_indicator_test.dart b/test/custom_shape_indicator_test.dart index 26a4c360..f605f7bf 100644 --- a/test/custom_shape_indicator_test.dart +++ b/test/custom_shape_indicator_test.dart @@ -16,6 +16,7 @@ import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/screenshot_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/widgets/draggable_widgets/placed_widget_builder.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/custom_circle_utility_widget.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/custom_rectangle_utility_widget.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/placed_custom_circle_widget.dart'; @@ -204,7 +205,7 @@ void main() { .every((indicator) => indicator.visible), isTrue, ); - expect(find.byIcon(Icons.rotate_right_rounded), findsOneWidget); + expect(find.byIcon(LucideIcons.rotateCw), findsOneWidget); final handle = find.byKey( const ValueKey('custom-rectangle-rotate-top-center'), @@ -231,7 +232,7 @@ void main() { final activeBadgeIcon = tester.widget( find.descendant( of: handle, - matching: find.byIcon(Icons.rotate_right_rounded), + matching: find.byIcon(LucideIcons.rotateCw), ), ); expect( diff --git a/test/vision_boundary_editor_widget_test.dart b/test/vision_boundary_editor_widget_test.dart index 27d22c05..aa96b6c9 100644 --- a/test/vision_boundary_editor_widget_test.dart +++ b/test/vision_boundary_editor_widget_test.dart @@ -73,7 +73,7 @@ void main() { VisionBoundaryEditScope.all, ); - await tester.tap(find.byIcon(Icons.keyboard_arrow_right)); + await tester.tap(find.byIcon(LucideIcons.chevronRight)); await tester.pumpAndSettle(); var state = container.read(visionBoundaryEditorProvider); expect(state.draft!.outer.first, initial.outer.first + const Offset(1, 0)); diff --git a/test/widgets/cloud_sync_status_chip_test.dart b/test/widgets/cloud_sync_button_test.dart similarity index 90% rename from test/widgets/cloud_sync_status_chip_test.dart rename to test/widgets/cloud_sync_button_test.dart index 4dd40202..19cacf0e 100644 --- a/test/widgets/cloud_sync_status_chip_test.dart +++ b/test/widgets/cloud_sync_button_test.dart @@ -13,7 +13,8 @@ import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_save_state_provider.dart'; import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; -import 'package:icarus/widgets/cloud_sync_status_chip.dart'; +import 'package:icarus/widgets/cloud_sync_button.dart'; +import 'package:icarus/widgets/editor_toolbar.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class _CloudStrategyProvider extends StrategyProvider { @@ -218,6 +219,17 @@ ProviderContainer _createConflictContainer({ ); } +Finder _syncButton(String status) => + find.byKey(ValueKey('cloud-sync-button-$status')); + +/// The one-line meaning the button shows on hover. +String _syncTooltip(WidgetTester tester) { + final button = tester.widget( + find.byType(EditorToolbarButton), + ); + return button.tooltip; +} + void main() { test('restored active-strategy media renders as syncing', () { final container = _createContainer( @@ -284,13 +296,13 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncStatusChip()), + home: Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); await tester.pump(); - expect(find.text('Synced'), findsOneWidget); + expect(_syncButton('synced'), findsOneWidget); container .read(textDraftProvider.notifier) @@ -298,18 +310,10 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); - expect(find.text('Editing…'), findsOneWidget); - expect(find.text('Synced'), findsNothing); + expect(_syncButton('editing'), findsOneWidget); + expect(_syncButton('synced'), findsNothing); - await tester.tap(find.text('Editing…')); - await tester.pumpAndSettle(); - expect(find.text('Edit not synced yet'), findsOneWidget); - expect( - find.text( - 'Finish editing or switch pages to send this change to the cloud.', - ), - findsOneWidget, - ); + expect(_syncTooltip(tester), 'Edit not synced yet'); }); testWidgets('offline remains visible while a text draft is active', @@ -324,16 +328,16 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncStatusChip()), + home: Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); - expect(find.text('Offline'), findsOneWidget); - expect(find.text('Editing…'), findsNothing); - expect(find.text('Synced'), findsNothing); + expect(_syncButton('offline'), findsOneWidget); + expect(_syncButton('editing'), findsNothing); + expect(_syncButton('synced'), findsNothing); }); test('status provider prioritizes connectivity over an offline flush error', @@ -443,14 +447,14 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncStatusChip()), + home: Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); await tester.pump(); - expect(find.text('Needs attention'), findsOneWidget); - await tester.tap(find.text('Needs attention')); + expect(_syncButton('attention'), findsOneWidget); + await tester.tap(_syncButton('attention')); await tester.pumpAndSettle(); expect( find.text( @@ -501,15 +505,15 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncStatusChip()), + home: Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); - expect(find.text('Offline'), findsOneWidget); - expect(find.text('Needs attention'), findsNothing); + expect(_syncButton('offline'), findsOneWidget); + expect(_syncButton('attention'), findsNothing); }); testWidgets('durability uncertainty never appears synced or reliable', @@ -528,7 +532,7 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncStatusChip()), + home: Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); @@ -536,10 +540,10 @@ void main() { final queue = container.read(strategyOpQueueProvider); expect(queue.outboxIsReliable, isFalse); - expect(find.text('Needs attention'), findsOneWidget); - expect(find.text('Synced'), findsNothing); + expect(_syncButton('attention'), findsOneWidget); + expect(_syncButton('synced'), findsNothing); - await tester.tap(find.text('Needs attention')); + await tester.tap(_syncButton('attention')); await tester.pumpAndSettle(); expect(find.text('Retry sync'), findsOneWidget); expect(find.textContaining('safely stored'), findsNothing); @@ -559,12 +563,12 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncStatusChip()), + home: Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); await tester.pump(); - await tester.tap(find.text('Needs attention')); + await tester.tap(_syncButton('attention')); await tester.pumpAndSettle(); expect(find.text('Use cloud'), findsOneWidget); @@ -600,12 +604,12 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncStatusChip()), + home: Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); await tester.pump(); - await tester.tap(find.text('Needs attention')); + await tester.tap(_syncButton('attention')); await tester.pumpAndSettle(); expect(find.text('Use cloud'), findsOneWidget); @@ -631,12 +635,12 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncStatusChip()), + home: Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); await tester.pump(); - await tester.tap(find.text('Needs attention')); + await tester.tap(_syncButton('attention')); await tester.pumpAndSettle(); expect(find.text('Use cloud'), findsOneWidget); @@ -667,12 +671,12 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncStatusChip()), + home: Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); await tester.pump(); - await tester.tap(find.text('Needs attention')); + await tester.tap(_syncButton('attention')); await tester.pumpAndSettle(); expect( @@ -698,12 +702,12 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncStatusChip()), + home: Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); await tester.pump(); - await tester.tap(find.text('Needs attention')); + await tester.tap(_syncButton('attention')); await tester.pumpAndSettle(); await tester.tap(find.text('Use cloud')); await tester.pumpAndSettle(); @@ -734,12 +738,12 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncStatusChip()), + home: Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); await tester.pump(); - await tester.tap(find.text('Needs attention')); + await tester.tap(_syncButton('attention')); await tester.pumpAndSettle(); await tester.tap(find.text('Use cloud')); await tester.pumpAndSettle(); From 20616b00b8af285731e3ba19a5b511d8d29f9fca Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:32:30 -0400 Subject: [PATCH 05/27] Finish the Lucide sweep in the color picker, expansion tile, demo tag, and delete area Co-Authored-By: Claude Fable 5.1 --- lib/widgets/better_color_picker.dart | 2 +- lib/widgets/color_picker_button.dart | 3 ++- lib/widgets/custom_expansion_tile.dart | 4 +++- lib/widgets/delete_area.dart | 5 +++-- lib/widgets/demo_tag.dart | 2 +- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/widgets/better_color_picker.dart b/lib/widgets/better_color_picker.dart index 538eca35..611e6e85 100644 --- a/lib/widgets/better_color_picker.dart +++ b/lib/widgets/better_color_picker.dart @@ -1004,7 +1004,7 @@ class _ModeField extends material.StatelessWidget { ); }, trailing: material.Icon( - material.Icons.unfold_more, + LucideIcons.chevronsUpDown, size: 16, color: palette.mutedForeground, ), diff --git a/lib/widgets/color_picker_button.dart b/lib/widgets/color_picker_button.dart index 9743d2d4..38d695a1 100644 --- a/lib/widgets/color_picker_button.dart +++ b/lib/widgets/color_picker_button.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class ColorPickerButton extends ConsumerStatefulWidget { @@ -61,7 +62,7 @@ class _ColorButtonsState extends ConsumerState { strokeAlign: BorderSide.strokeAlignCenter, ), ), - child: const Icon(Icons.add), + child: const Icon(LucideIcons.plus, size: 18), ), ), ), diff --git a/lib/widgets/custom_expansion_tile.dart b/lib/widgets/custom_expansion_tile.dart index a9c5227f..b52e59d7 100644 --- a/lib/widgets/custom_expansion_tile.dart +++ b/lib/widgets/custom_expansion_tile.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; class CustomExpansionTile extends StatefulWidget { /// Creates a custom expansion tile that keeps a persistent row visible @@ -175,7 +176,8 @@ class _CustomExpansionTileState extends State RotationTransition( turns: _iconTurns, child: Icon( - Icons.expand_more, + LucideIcons.chevronDown, + size: 20, color: iconColor, ), ), diff --git a/lib/widgets/delete_area.dart b/lib/widgets/delete_area.dart index fb0916c9..55c8066b 100644 --- a/lib/widgets/delete_area.dart +++ b/lib/widgets/delete_area.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/placed_classes.dart'; @@ -329,8 +330,8 @@ class _DeleteAreaState extends ConsumerState children: [ Center( child: Icon( - Icons.delete_outline, - size: 24, + LucideIcons.trash2, + size: 22, color: iconColor, ), ), diff --git a/lib/widgets/demo_tag.dart b/lib/widgets/demo_tag.dart index 95881eef..bef152cf 100644 --- a/lib/widgets/demo_tag.dart +++ b/lib/widgets/demo_tag.dart @@ -43,7 +43,7 @@ class DemoTag extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ Icon( - Icons.science_outlined, + LucideIcons.flaskConical, size: 14, color: Colors.white.withAlpha(230), // 0.9 ), From b911182f422ca9dd0e2af1e1a849dfcd0ca50565 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:34:25 -0400 Subject: [PATCH 06/27] Document the editor toolbar and icon rules in DESIGN.md Co-Authored-By: Claude Fable 5.1 --- DESIGN.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/DESIGN.md b/DESIGN.md index cacd3153..53d32c83 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -17,6 +17,12 @@ The palette, theme, and sizing constants live in `lib/const/settings.dart`, with - Desktop builds hide the native title bar. Each top-level screen draws its own 40px strip (`lib/widgets/window_chrome.dart`): macOS keeps its traffic lights, so the strip leaves a 78px inset on the left; Windows and Linux get app-drawn caption buttons on the right; the strip is the drag handle. Web renders the same strip with no inset and no buttons. - The library strip holds the three tabs on the left and only search, sort, New, and the account on the right. Nothing else goes in it. Inside a folder, the breadcrumb lives in the content area, not the strip. +- The editor's document actions (save, export, video, screenshot, settings) sit in one card at the top-left of the canvas (`lib/widgets/editor_toolbar.dart`). For a cloud strategy the save button's glyph is the sync state; the words live in its tooltip and a popover opens only when sync needs attention. No status chips or labels in the editor. + +## Icons + +- Lucide is the icon family (`LucideIcons.*`, re-exported by shadcn_ui). Material `Icons.*` only survive in the persisted folder-icon registry and cursor glyphs. Toolbar and tool glyphs are 18-22px, menu and inline glyphs 16px. +- Icons rest in muted foreground and come up to foreground on hover. Violet on an icon means it is the selected or checked item, nothing else. ## Things I would like to remain consistent From 2c63d3e601e3323d0b80e3fc1e54f01629658297 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:36:50 -0400 Subject: [PATCH 07/27] Focus the rename and folder dialogs on open and submit on Enter Co-Authored-By: Claude Fable 5.1 --- lib/widgets/dialogs/strategy/rename_strategy_dialog.dart | 2 +- lib/widgets/folder_edit_dialog.dart | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart b/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart index 2ffc901c..6db6cf03 100644 --- a/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart +++ b/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart @@ -78,9 +78,9 @@ class _RenameStrategyDialogState extends ConsumerState { child: Padding( padding: const EdgeInsets.all(8.0), child: CustomTextField( - // onEnterPressed: (intent) {}, hintText: widget.currentName, controller: _textController, + autofocus: true, textAlign: TextAlign.start, onSubmitted: (value) async { if (value.isNotEmpty) { diff --git a/lib/widgets/folder_edit_dialog.dart b/lib/widgets/folder_edit_dialog.dart index 566d4fb9..91efbadd 100644 --- a/lib/widgets/folder_edit_dialog.dart +++ b/lib/widgets/folder_edit_dialog.dart @@ -134,6 +134,10 @@ class _FolderEditDialogState extends ConsumerState { child: CustomTextField( hintText: "Folder Name", controller: _folderNameController, + autofocus: true, + onSubmitted: (_) { + if (!_isSubmitting) _submit(); + }, ), ), // const SizedBox(width: 30), From 946ee3b5989a39138e3f39a992d9e96160b60a28 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:37:29 -0400 Subject: [PATCH 08/27] Format the sync button test Co-Authored-By: Claude Fable 5.1 --- test/widgets/cloud_sync_button_test.dart | 33 ++++++++++++++++-------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/test/widgets/cloud_sync_button_test.dart b/test/widgets/cloud_sync_button_test.dart index 19cacf0e..90713ee2 100644 --- a/test/widgets/cloud_sync_button_test.dart +++ b/test/widgets/cloud_sync_button_test.dart @@ -296,7 +296,8 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); @@ -328,7 +329,8 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); @@ -447,7 +449,8 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); @@ -505,7 +508,8 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); @@ -532,7 +536,8 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); @@ -563,7 +568,8 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); @@ -604,7 +610,8 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); @@ -635,7 +642,8 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); @@ -671,7 +679,8 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); @@ -702,7 +711,8 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); @@ -738,7 +748,8 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); From 1acfe8a04e9a2916a311285bb0589af1db9414d9 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:31:00 -0400 Subject: [PATCH 09/27] Rest toolbar glyphs on full foreground and size the cloud glyph to its neighbours Co-Authored-By: Claude Fable 5.1 --- lib/widgets/cloud_sync_button.dart | 8 +++++--- lib/widgets/editor_toolbar.dart | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/widgets/cloud_sync_button.dart b/lib/widgets/cloud_sync_button.dart index a971bf5b..5a87242c 100644 --- a/lib/widgets/cloud_sync_button.dart +++ b/lib/widgets/cloud_sync_button.dart @@ -240,7 +240,9 @@ class _CloudSyncButtonState extends ConsumerState { } Widget _glyph(_SyncStatus status, Color? color) { - final size = widget.style.iconSize; + // Lucide's cloud sits low in its box and reads smaller than the upload + // and camera glyphs beside it, so it gets 2px more. + final size = widget.style.iconSize + 2; switch (status) { case _SyncStatus.synced: return Icon( @@ -259,8 +261,8 @@ class _CloudSyncButtonState extends ConsumerState { case _SyncStatus.syncing: return SizedBox( key: const ValueKey('syncing'), - width: size - 2, - height: size - 2, + width: size - 4, + height: size - 4, child: CircularProgressIndicator( strokeWidth: 1.8, valueColor: AlwaysStoppedAnimation( diff --git a/lib/widgets/editor_toolbar.dart b/lib/widgets/editor_toolbar.dart index 643b8594..dcf42e66 100644 --- a/lib/widgets/editor_toolbar.dart +++ b/lib/widgets/editor_toolbar.dart @@ -277,8 +277,8 @@ class _EditorToolbarState extends ConsumerState { } } -/// One control in the editor toolbar: a quiet ghost icon button that comes up -/// to full foreground on hover. [icon] is any 18px glyph, so buttons can swap +/// One control in the editor toolbar: a ghost icon button in full foreground +/// (muted glyphs vanish against the card) with a tonal hover. [icon] is any 18px glyph, so buttons can swap /// in a spinner without changing size. class EditorToolbarButton extends StatelessWidget { const EditorToolbarButton({ @@ -305,7 +305,7 @@ class EditorToolbarButton extends StatelessWidget { @override Widget build(BuildContext context) { const theme = Settings.tacticalVioletTheme; - final resting = foregroundColor ?? theme.mutedForeground; + final resting = foregroundColor ?? theme.foreground; return Semantics( label: semanticsLabel ?? tooltip, button: true, From 1a05db6427932668b5b44cfb5a63df2c9d476786 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:26:30 -0400 Subject: [PATCH 10/27] Draw editor toolbar glyphs at the 300 stroke weight The default 2px Lucide stroke reads heavy in white at 18px; the lighter weight keeps the size and contrast and loses the bulk. Co-Authored-By: Claude Fable 5.1 --- lib/strategy_view.dart | 2 +- lib/widgets/cloud_sync_button.dart | 8 ++++---- lib/widgets/editor_toolbar.dart | 15 ++++++++------- lib/widgets/strategy_save_icon_button.dart | 4 ++-- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart index ca1dbf61..dcc9dd2b 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -236,7 +236,7 @@ class _StrategyViewState extends ConsumerState child: ShadIconButton.ghost( foregroundColor: Colors.white, onPressed: _leaveToLibrary, - icon: const Icon(LucideIcons.house, size: 20), + icon: const Icon(LucideIcons.house300, size: 20), ), ), const SizedBox(width: 5), diff --git a/lib/widgets/cloud_sync_button.dart b/lib/widgets/cloud_sync_button.dart index 5a87242c..77e651db 100644 --- a/lib/widgets/cloud_sync_button.dart +++ b/lib/widgets/cloud_sync_button.dart @@ -246,14 +246,14 @@ class _CloudSyncButtonState extends ConsumerState { switch (status) { case _SyncStatus.synced: return Icon( - LucideIcons.cloudCheck, + LucideIcons.cloudCheck300, key: const ValueKey('synced'), size: size, color: color, ); case _SyncStatus.editing: return Icon( - LucideIcons.cloudUpload, + LucideIcons.cloudUpload300, key: const ValueKey('editing'), size: size, color: color, @@ -272,14 +272,14 @@ class _CloudSyncButtonState extends ConsumerState { ); case _SyncStatus.offline: return Icon( - LucideIcons.cloudOff, + LucideIcons.cloudOff300, key: const ValueKey('offline'), size: size, color: color, ); case _SyncStatus.attention: return Icon( - LucideIcons.cloudAlert, + LucideIcons.cloudAlert300, key: const ValueKey('attention'), size: size, color: color, diff --git a/lib/widgets/editor_toolbar.dart b/lib/widgets/editor_toolbar.dart index dcf42e66..686ce29c 100644 --- a/lib/widgets/editor_toolbar.dart +++ b/lib/widgets/editor_toolbar.dart @@ -83,13 +83,13 @@ class _EditorToolbarState extends ConsumerState { style: style, tooltip: 'Export .ica', onPressed: _exportStrategy, - icon: const Icon(LucideIcons.upload), + icon: const Icon(LucideIcons.upload300), ), EditorToolbarButton( style: style, tooltip: 'Export video', onPressed: _exportVideo, - icon: const Icon(LucideIcons.clapperboard), + icon: const Icon(LucideIcons.clapperboard300), ), EditorToolbarButton( style: style, @@ -106,7 +106,7 @@ class _EditorToolbarState extends ConsumerState { ), ), ) - : const Icon(LucideIcons.camera), + : const Icon(LucideIcons.camera300), ), const EditorToolbarDivider(), EditorToolbarButton( @@ -118,7 +118,7 @@ class _EditorToolbarState extends ConsumerState { builder: (context) => const SettingsTab(), ); }, - icon: const Icon(LucideIcons.settings), + icon: const Icon(LucideIcons.settings300), ), ], ), @@ -277,8 +277,9 @@ class _EditorToolbarState extends ConsumerState { } } -/// One control in the editor toolbar: a ghost icon button in full foreground -/// (muted glyphs vanish against the card) with a tonal hover. [icon] is any 18px glyph, so buttons can swap +/// One control in the editor toolbar. Glyphs are the 300 stroke weight: the +/// default 2px Lucide stroke reads heavy in white at 18px, and muted grey +/// vanishes against the card, so the weight carries the quietness instead. [icon] is any 18px glyph, so buttons can swap /// in a spinner without changing size. class EditorToolbarButton extends StatelessWidget { const EditorToolbarButton({ @@ -371,7 +372,7 @@ class _ViewOnlyChip extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(LucideIcons.eye, size: 14, color: theme.mutedForeground), + Icon(LucideIcons.eye300, size: 14, color: theme.mutedForeground), const SizedBox(width: 6), Text( 'View only', diff --git a/lib/widgets/strategy_save_icon_button.dart b/lib/widgets/strategy_save_icon_button.dart index 511e12bf..596a4556 100644 --- a/lib/widgets/strategy_save_icon_button.dart +++ b/lib/widgets/strategy_save_icon_button.dart @@ -116,7 +116,7 @@ class _AutoSaveButtonState extends ConsumerState { final size = widget.style.iconSize; final Widget icon = switch (_phase) { - _Phase.idle => Icon(LucideIcons.save, key: const ValueKey('idle')), + _Phase.idle => Icon(LucideIcons.save300, key: const ValueKey('idle')), _Phase.loading => SizedBox( key: const ValueKey('loading'), width: size - 2, @@ -129,7 +129,7 @@ class _AutoSaveButtonState extends ConsumerState { ), ), _Phase.success => Icon( - LucideIcons.check, + LucideIcons.check300, key: const ValueKey('success'), color: Settings.allyBGColor, ), From 35a0a2a2c2a2451e690f517de08df1ec0700ce42 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:35:16 -0400 Subject: [PATCH 11/27] Give the editor the library's 40px strip and stop moving the traffic lights The editor used to ask macOS to re-center the traffic lights on a 65px band so the map card could share the title bar, which left the lights floating and jumping between screens. Every screen now draws the same 40px strip: Library on the left, the strategy name in the middle, Discord on the right. The map card moves onto the canvas above the toolbar, both on the 8px gutter the pages bar already uses. The window_chrome channel and the Swift handler that moved the lights are gone. Co-Authored-By: Claude Fable 5.1 --- lib/strategy_view.dart | 162 ++++++++++-------- lib/widgets/editor_toolbar.dart | 131 +++++++------- lib/widgets/map_selector.dart | 7 +- lib/widgets/strategy_quick_switcher.dart | 2 +- lib/widgets/strategy_view_skeleton.dart | 111 ++++++------ lib/widgets/window_chrome.dart | 80 +-------- macos/Runner/MainFlutterWindow.swift | 31 +--- test/strategy_view_skeleton_test.dart | 9 +- .../strategy_quick_switcher_layout_test.dart | 28 +-- 9 files changed, 231 insertions(+), 330 deletions(-) diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart index dcc9dd2b..c3a211f5 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -218,75 +218,42 @@ class _StrategyViewState extends ConsumerState return Scaffold( body: Column( children: [ - EditorWindowHeader( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 15), - child: LayoutBuilder( - builder: (context, constraints) { - final showDiscordLabel = constraints.maxWidth >= 1000; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - ShadTooltip( - builder: (context) => const Text('Library'), - child: ShadIconButton.ghost( - foregroundColor: Colors.white, - onPressed: _leaveToLibrary, - icon: const Icon(LucideIcons.house300, size: 20), - ), - ), - const SizedBox(width: 5), - const StrategyEditBoundary( - disabledOpacity: 0.55, - child: MapSelector(), - ), - if (kIsWeb) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 8.0), - child: DemoTag(), - ), - ], + // The same 40px strip as the library, so the traffic lights never + // move: Library on the left, the strategy in the middle, Discord on + // the right. The map card lives on the canvas with the toolbar. + AppWindowStrip( + child: Stack( + children: [ + Row( + children: [ + const SizedBox(width: 6), + ShadTooltip( + builder: (context) => const Text('Library'), + child: ShadIconButton.ghost( + width: 28, + height: 28, + foregroundColor: + Settings.tacticalVioletTheme.mutedForeground, + hoverForegroundColor: + Settings.tacticalVioletTheme.foreground, + onPressed: _leaveToLibrary, + icon: const Icon(LucideIcons.house300, size: 18), ), - const StrategyQuickSwitcher(), - if (showDiscordLabel) - TextButton( - style: TextButton.styleFrom( - foregroundColor: Colors.white, - enabledMouseCursor: SystemMouseCursors.click, - ), - onPressed: () async { - await launchUrl(Settings.dicordLink); - }, - child: const Row( - children: [ - Text("Have any bugs? Join the Discord"), - SizedBox(width: 10), - Icon( - CustomIcons.discord, - color: Colors.white, - ), - ], - ), - ) - else - Tooltip( - message: 'Have any bugs? Join the Discord', - child: ShadIconButton.ghost( - foregroundColor: Colors.white, - onPressed: () async { - await launchUrl(Settings.dicordLink); - }, - icon: const Icon(CustomIcons.discord), - ), - ), - ], - ); - }, - ), + ), + if (kIsWeb) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8.0), + child: DemoTag(), + ), + const Expanded( + child: WindowDragArea(child: SizedBox.expand()), + ), + const _DiscordLink(), + const SizedBox(width: 10), + ], + ), + const Center(child: StrategyQuickSwitcher()), + ], ), ), const Expanded( @@ -300,7 +267,24 @@ class _StrategyViewState extends ConsumerState alignment: Alignment.centerLeft, child: RepaintBoundary(child: InteractiveMap()), ), - Align(alignment: Alignment.topLeft, child: EditorToolbar()), + Align( + alignment: Alignment.topLeft, + child: Padding( + padding: EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + StrategyEditBoundary( + disabledOpacity: 0.55, + child: MapSelector(), + ), + SizedBox(height: 8), + EditorToolbar(), + ], + ), + ), + ), Align( alignment: Alignment.bottomLeft, child: Padding( @@ -345,3 +329,43 @@ class _StrategyViewState extends ConsumerState ); } } + +/// The bug-report link at the end of the editor strip: text when there is +/// room, the glyph alone when there isn't. +class _DiscordLink extends StatelessWidget { + const _DiscordLink(); + + @override + Widget build(BuildContext context) { + const theme = Settings.tacticalVioletTheme; + final showLabel = MediaQuery.sizeOf(context).width >= 1000; + final button = ShadButton.ghost( + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 8), + foregroundColor: theme.mutedForeground, + hoverForegroundColor: theme.foreground, + onPressed: () async { + await launchUrl(Settings.dicordLink); + }, + leading: showLabel ? null : const Icon(CustomIcons.discord, size: 16), + child: showLabel + ? const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Have any bugs? Join the Discord', + style: TextStyle(fontSize: 13), + ), + SizedBox(width: 8), + Icon(CustomIcons.discord, size: 16), + ], + ) + : const SizedBox.shrink(), + ); + if (showLabel) return button; + return ShadTooltip( + builder: (context) => const Text('Have any bugs? Join the Discord'), + child: button, + ); + } +} diff --git a/lib/widgets/editor_toolbar.dart b/lib/widgets/editor_toolbar.dart index 686ce29c..b988bbb2 100644 --- a/lib/widgets/editor_toolbar.dart +++ b/lib/widgets/editor_toolbar.dart @@ -43,8 +43,8 @@ class EditorToolbarButtonStyle { const EditorToolbarButtonStyle kEditorToolbarButtonStyle = EditorToolbarButtonStyle(); -/// The document actions of the open strategy, docked at the top-left of the -/// canvas as one card: save, export, video, screenshot, then settings. +/// The document actions of the open strategy, one card under the map card at +/// the top-left of the canvas: save, export, video, screenshot, then settings. class EditorToolbar extends ConsumerStatefulWidget { const EditorToolbar({super.key}); @@ -61,74 +61,71 @@ class _EditorToolbarState extends ConsumerState { final source = ref.watch(strategyProvider.select((value) => value.source)); final isCloud = source == StrategySource.cloud; - return Padding( - padding: const EdgeInsets.all(8), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.card, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Settings.tacticalVioletTheme.border), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isCloud) - const CloudSyncButton(style: style) - else - const AutoSaveButton(style: style), - EditorToolbarButton( - style: style, - tooltip: 'Export .ica', - onPressed: _exportStrategy, - icon: const Icon(LucideIcons.upload300), - ), - EditorToolbarButton( - style: style, - tooltip: 'Export video', - onPressed: _exportVideo, - icon: const Icon(LucideIcons.clapperboard300), - ), - EditorToolbarButton( - style: style, - tooltip: 'Screenshot', - onPressed: _captureScreenshot, - icon: _isCapturingScreenshot - ? SizedBox( - width: style.iconSize - 2, - height: style.iconSize - 2, - child: CircularProgressIndicator( - strokeWidth: 1.8, - valueColor: AlwaysStoppedAnimation( - Settings.tacticalVioletTheme.mutedForeground, - ), + return Row( + children: [ + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.card, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Settings.tacticalVioletTheme.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isCloud) + const CloudSyncButton(style: style) + else + const AutoSaveButton(style: style), + EditorToolbarButton( + style: style, + tooltip: 'Export .ica', + onPressed: _exportStrategy, + icon: const Icon(LucideIcons.upload300), + ), + EditorToolbarButton( + style: style, + tooltip: 'Export video', + onPressed: _exportVideo, + icon: const Icon(LucideIcons.clapperboard300), + ), + EditorToolbarButton( + style: style, + tooltip: 'Screenshot', + onPressed: _captureScreenshot, + icon: _isCapturingScreenshot + ? SizedBox( + width: style.iconSize - 2, + height: style.iconSize - 2, + child: CircularProgressIndicator( + strokeWidth: 1.8, + valueColor: AlwaysStoppedAnimation( + Settings.tacticalVioletTheme.mutedForeground, ), - ) - : const Icon(LucideIcons.camera300), - ), - const EditorToolbarDivider(), - EditorToolbarButton( - style: style, - tooltip: 'Settings', - onPressed: () { - showShadDialog( - context: context, - builder: (context) => const SettingsTab(), - ); - }, - icon: const Icon(LucideIcons.settings300), - ), - ], - ), + ), + ) + : const Icon(LucideIcons.camera300), + ), + const EditorToolbarDivider(), + EditorToolbarButton( + style: style, + tooltip: 'Settings', + onPressed: () { + showShadDialog( + context: context, + builder: (context) => const SettingsTab(), + ); + }, + icon: const Icon(LucideIcons.settings300), + ), + ], ), - if (_isViewOnly()) ...[ - const SizedBox(width: 8), - const _ViewOnlyChip(), - ], + ), + if (_isViewOnly()) ...[ + const SizedBox(width: 8), + const _ViewOnlyChip(), ], - ), + ], ); } diff --git a/lib/widgets/map_selector.dart b/lib/widgets/map_selector.dart index edb8f2a9..fe179a82 100644 --- a/lib/widgets/map_selector.dart +++ b/lib/widgets/map_selector.dart @@ -18,7 +18,7 @@ class MapSelector extends ConsumerStatefulWidget { class _MapSelectorState extends ConsumerState { static const double _cardWidth = 262; static const double _cardHeight = 65; - static const double _outerRadius = 10; + static const double _outerRadius = 12; static const double _innerGap = 4; static const double _innerRadius = _outerRadius - _innerGap; static const double _sideToggleWidth = 66; @@ -77,10 +77,7 @@ class _MapSelectorState extends ConsumerState { decoration: BoxDecoration( color: Settings.tacticalVioletTheme.card, borderRadius: const BorderRadius.all(Radius.circular(_outerRadius)), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - width: 2, - ), + border: Border.all(color: Settings.tacticalVioletTheme.border), ), width: _cardWidth, height: _cardHeight, diff --git a/lib/widgets/strategy_quick_switcher.dart b/lib/widgets/strategy_quick_switcher.dart index 79f1f3bd..293fb49b 100644 --- a/lib/widgets/strategy_quick_switcher.dart +++ b/lib/widgets/strategy_quick_switcher.dart @@ -27,7 +27,7 @@ class StrategyQuickSwitcher extends ConsumerStatefulWidget { class _StrategyQuickSwitcherState extends ConsumerState { static const double _barWidth = 280; - static const double _barHeight = 40; + static const double _barHeight = 30; static const EdgeInsets _displayMargin = EdgeInsets.symmetric(horizontal: 16); final OverlayPortalController _controller = OverlayPortalController(); final LayerLink _layerLink = LayerLink(); diff --git a/lib/widgets/strategy_view_skeleton.dart b/lib/widgets/strategy_view_skeleton.dart index 74c6b61f..492ec138 100644 --- a/lib/widgets/strategy_view_skeleton.dart +++ b/lib/widgets/strategy_view_skeleton.dart @@ -42,11 +42,22 @@ class StrategyViewSkeleton extends StatelessWidget { isAttack: isAttack, ), ), - const Align( + Align( alignment: Alignment.topLeft, child: Padding( - padding: EdgeInsets.all(8), - child: _FloatingControlSkeleton(), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _MapSelectorSkeleton( + mapValue: resolvedMap, + isAttack: isAttack, + ), + const SizedBox(height: 8), + const _FloatingControlSkeleton(), + ], + ), ), ), const Align( @@ -157,68 +168,47 @@ class _SkeletonTopBar extends StatelessWidget { @override Widget build(BuildContext context) { final title = strategyName?.trim(); - return EditorWindowHeader(child: _buildBar(context, title)); - } - - Widget _buildBar(BuildContext context, String? title) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 15), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, + return AppWindowStrip( + child: Stack( children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, + const Row( children: [ - const _SkeletonBlock(width: 40, height: 40, radius: 8), - const SizedBox(width: 5), - _MapSelectorSkeleton(mapValue: mapValue, isAttack: isAttack), + SizedBox(width: 6), + _SkeletonBlock(width: 28, height: 28, radius: 8), + Spacer(), + _SkeletonBlock(width: 200, height: 28, radius: 8), + SizedBox(width: 10), ], ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + Center( + child: Container( + width: 280, + height: 30, + decoration: BoxDecoration( + color: _tone(Settings.tacticalVioletTheme.card, 0.95), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _tone(Settings.highlightColor, 0.82), + ), + ), child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 280), - child: Container( - height: 40, - decoration: BoxDecoration( - color: _tone(Settings.tacticalVioletTheme.card, 0.95), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: _tone(Settings.highlightColor, 0.82), + child: title == null || title.isEmpty + ? const _SkeletonBlock(width: 158, height: 12, radius: 5) + : Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ShadTheme.of(context) + .textTheme + .small + .copyWith(color: Colors.white70), + ), ), - ), - child: Center( - child: title == null || title.isEmpty - ? const _SkeletonBlock( - width: 158, - height: 12, - radius: 5, - ) - : Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16), - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: ShadTheme.of( - context, - ) - .textTheme - .small - .copyWith(color: Colors.white70), - ), - ), - ), - ), - ), ), ), ), - const _SkeletonBlock(width: 238, height: 40, radius: 8), ], ), ); @@ -228,7 +218,7 @@ class _SkeletonTopBar extends StatelessWidget { class _MapSelectorSkeleton extends StatelessWidget { const _MapSelectorSkeleton({required this.mapValue, required this.isAttack}); - static const double _outerRadius = 10; + static const double _outerRadius = 12; static const double _innerGap = 4; static const double _innerRadius = _outerRadius - _innerGap; @@ -244,11 +234,8 @@ class _MapSelectorSkeleton extends StatelessWidget { padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: Settings.tacticalVioletTheme.card, - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - width: 2, - ), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Settings.tacticalVioletTheme.border), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, diff --git a/lib/widgets/window_chrome.dart b/lib/widgets/window_chrome.dart index dc9b3c7e..7ba3f4bd 100644 --- a/lib/widgets/window_chrome.dart +++ b/lib/widgets/window_chrome.dart @@ -1,38 +1,18 @@ import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, kIsWeb; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:icarus/const/settings.dart'; import 'package:window_manager/window_manager.dart'; -/// Height of the strip the app draws in place of the native title bar. -/// `macos/Runner/MainFlutterWindow.swift` centers the traffic lights on it. +/// Height of the strip the app draws in place of the native title bar. Every +/// screen uses it, so `macos/Runner/MainFlutterWindow.swift` can center the +/// traffic lights on it once and never move them. const double kWindowStripHeight = 40; -/// Height of the editor's header band: the 65px map card. -const double kEditorHeaderHeight = 65; - /// Room reserved on the left for the native macOS traffic lights. const double kMacTrafficLightInset = 78; -/// Talks to `MainFlutterWindow.swift`, which centers the traffic lights on -/// whatever band height the current screen reports. -const MethodChannel _chromeChannel = MethodChannel('icarus/window_chrome'); - -int _editorHeadersMounted = 0; - -Future _syncMacTitleStripHeight() async { - if (!_isMacOS) return; - final height = - _editorHeadersMounted > 0 ? kEditorHeaderHeight : kWindowStripHeight; - try { - await _chromeChannel.invokeMethod('setTitleStripHeight', height); - } on MissingPluginException { - // Running without the macOS runner (tests, other hosts). - } -} - bool get _isMacOS => !kIsWeb && defaultTargetPlatform == TargetPlatform.macOS; bool get _isWindows => !kIsWeb && defaultTargetPlatform == TargetPlatform.windows; @@ -60,60 +40,6 @@ class WindowDragArea extends StatelessWidget { } } -/// The editor's header: controls and the map card centered on one band, -/// with the traffic lights (macOS) or caption buttons (Windows, Linux) on -/// that same line. On macOS the window is told the band's height so the -/// lights move down to meet it, and back up when the editor closes. Windows -/// and Linux split the canvas gap evenly above and below the band. -class EditorWindowHeader extends StatefulWidget { - const EditorWindowHeader({super.key, required this.child}); - - final Widget child; - - @override - State createState() => _EditorWindowHeaderState(); -} - -class _EditorWindowHeaderState extends State { - @override - void initState() { - super.initState(); - _editorHeadersMounted++; - _syncMacTitleStripHeight(); - } - - @override - void dispose() { - // The skeleton and the real header swap within one frame, so count - // mounts instead of assuming this was the last one. - _editorHeadersMounted--; - _syncMacTitleStripHeight(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return WindowDragArea( - child: Padding( - padding: _drawsCaptionButtons - ? const EdgeInsets.symmetric(vertical: 5) - : const EdgeInsets.only(bottom: 10), - child: SizedBox( - height: kEditorHeaderHeight, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const MacTrafficLightInset(), - Expanded(child: widget.child), - const WindowCaptionButtons(), - ], - ), - ), - ), - ); - } -} - /// The compact Icarus lockup at the start of the Windows library strip. class WindowsIcarusWordmark extends StatelessWidget { const WindowsIcarusWordmark({super.key}); diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 245f4504..18da4aca 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -4,13 +4,11 @@ import FlutterMacOS /// Icarus draws its own title strip, so the window keeps only the native /// traffic lights and lets the Flutter view extend under the title bar. class MainFlutterWindow: NSWindow { - /// Height of the band the traffic lights are centered on. Starts at the - /// library strip's height (`kWindowStripHeight` in - /// lib/widgets/window_chrome.dart); screens with a taller header, like the - /// editor, update it over the `icarus/window_chrome` channel. - private var titleStripHeight: CGFloat = 40 + /// Height of the strip every screen draws in place of the title bar + /// (`kWindowStripHeight` in lib/widgets/window_chrome.dart). The traffic + /// lights are centered on it once and never move. + private let titleStripHeight: CGFloat = 40 private var layoutObservers: [NSObjectProtocol] = [] - private var chromeChannel: FlutterMethodChannel? override func awakeFromNib() { let flutterViewController = FlutterViewController() @@ -24,27 +22,6 @@ class MainFlutterWindow: NSWindow { RegisterGeneratedPlugins(registry: flutterViewController) - let channel = FlutterMethodChannel( - name: "icarus/window_chrome", - binaryMessenger: flutterViewController.engine.binaryMessenger - ) - channel.setMethodCallHandler { [weak self] call, result in - guard let self = self else { return } - switch call.method { - case "setTitleStripHeight": - guard let height = call.arguments as? Double else { - result(FlutterError(code: "bad-args", message: "height missing", details: nil)) - return - } - self.titleStripHeight = CGFloat(height) - self.centerTrafficLights() - result(nil) - default: - result(FlutterMethodNotImplemented) - } - } - chromeChannel = channel - super.awakeFromNib() observeTitleBarLayout() diff --git a/test/strategy_view_skeleton_test.dart b/test/strategy_view_skeleton_test.dart index 2d724b89..4b3eb7a4 100644 --- a/test/strategy_view_skeleton_test.dart +++ b/test/strategy_view_skeleton_test.dart @@ -37,12 +37,17 @@ void main() { debugDefaultTargetPlatformOverride = null; } + final strip = tester.getRect(find.byType(AppWindowStrip)); final mapThumbnail = tester.getRect(find.byType(Image).first); final strategyTitle = tester.getRect(find.text('SYNC BOUNDARY PROBE')); final captionButtons = tester.getRect(find.byType(WindowCaptionButtons)); - expect(strategyTitle.center.dy, mapThumbnail.center.dy); - expect(captionButtons.center.dy, mapThumbnail.center.dy); + // Title and caption buttons share the strip; the map card sits on the + // canvas below it, like the real editor. + expect(strip.height, kWindowStripHeight); + expect(strategyTitle.center.dy, closeTo(strip.center.dy, 0.5)); + expect(captionButtons.center.dy, closeTo(strip.center.dy, 0.5)); + expect(mapThumbnail.top, greaterThan(strip.bottom)); expect(tester.takeException(), isNull); }); } diff --git a/test/widgets/strategy_quick_switcher_layout_test.dart b/test/widgets/strategy_quick_switcher_layout_test.dart index ab520206..0fff7179 100644 --- a/test/widgets/strategy_quick_switcher_layout_test.dart +++ b/test/widgets/strategy_quick_switcher_layout_test.dart @@ -49,18 +49,8 @@ void main() { home: const Scaffold( body: Align( alignment: Alignment.topCenter, - child: EditorWindowHeader( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - SizedBox( - key: ValueKey('map-card-reference'), - width: 262, - height: 65, - ), - StrategyQuickSwitcher(), - ], - ), + child: AppWindowStrip( + child: Center(child: StrategyQuickSwitcher()), ), ), ), @@ -75,16 +65,14 @@ void main() { final control = tester.getRect( find.byKey(const ValueKey('strategy-quick-switcher-control')), ); - final mapCard = tester.getRect( - find.byKey(const ValueKey('map-card-reference')), - ); final captionButtons = tester.getRect(find.byType(WindowCaptionButtons)); - final header = tester.getRect(find.byType(EditorWindowHeader)); + final strip = tester.getRect(find.byType(AppWindowStrip)); - expect(control.center.dy, mapCard.center.dy); - expect(captionButtons.center.dy, mapCard.center.dy); - expect(mapCard.top - header.top, header.bottom - mapCard.bottom); - expect(control.height, 40); + expect(strip.height, kWindowStripHeight); + // The strip's 1px bottom border sits outside its content box. + expect(control.center.dy, closeTo(strip.center.dy, 0.5)); + expect(captionButtons.center.dy, closeTo(strip.center.dy, 0.5)); + expect(control.height, 30); expect(tester.takeException(), isNull); }); } From 16e210c0f167b48419308d19e3b3d95b2cec25d4 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:38:23 -0400 Subject: [PATCH 12/27] Keep an 8px gap between the editor strip and the floating panels Co-Authored-By: Claude Fable 5.1 --- lib/strategy_view.dart | 84 ++++++++++++++++++++++-------------------- 1 file changed, 44 insertions(+), 40 deletions(-) diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart index c3a211f5..dc111eaa 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -256,50 +256,54 @@ class _StrategyViewState extends ConsumerState ], ), ), - const Expanded( - child: Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill( - child: StrategyEditBoundary(child: DeleteCapture()), - ), - Align( - alignment: Alignment.centerLeft, - child: RepaintBoundary(child: InteractiveMap()), - ), - Align( - alignment: Alignment.topLeft, - child: Padding( - padding: EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - StrategyEditBoundary( - disabledOpacity: 0.55, - child: MapSelector(), - ), - SizedBox(height: 8), - EditorToolbar(), - ], + // Breathing room under the strip; every floating panel starts here. + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 8), + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: StrategyEditBoundary(child: DeleteCapture()), + ), + Align( + alignment: Alignment.centerLeft, + child: RepaintBoundary(child: InteractiveMap()), + ), + Align( + alignment: Alignment.topLeft, + child: Padding( + padding: EdgeInsets.fromLTRB(8, 0, 8, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + StrategyEditBoundary( + disabledOpacity: 0.55, + child: MapSelector(), + ), + SizedBox(height: 8), + EditorToolbar(), + ], + ), ), ), - ), - Align( - alignment: Alignment.bottomLeft, - child: Padding( - padding: EdgeInsets.all(8.0), - child: PagesBar(), + Align( + alignment: Alignment.bottomLeft, + child: Padding( + padding: EdgeInsets.all(8.0), + child: PagesBar(), + ), ), - ), - Align( - alignment: Alignment.centerRight, - child: StrategyEditBoundary( - disabledOpacity: 0.55, - child: SideBarUI(), + Align( + alignment: Alignment.centerRight, + child: StrategyEditBoundary( + disabledOpacity: 0.55, + child: SideBarUI(), + ), ), - ), - ], + ], + ), ), ), ], From 32ad099e872fd41323980e6eba0a0e5f5506b550 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:44:34 -0400 Subject: [PATCH 13/27] Use the redesigned folder card for cloud folders too The cloud library kept the old folder pill because FolderCard only knew the local store. The card now carries the store and the user's cloud role, so dragging into it, opening, editing, sharing, and deleting go to the right library and shared folders stay read-only. Cloud folders show their subfolder count; their strategies load per folder so the card leaves that count out rather than claiming they are empty. The pill is gone. Co-Authored-By: Claude Fable 5.1 --- lib/widgets/folder_card.dart | 150 ++++++--- lib/widgets/folder_content.dart | 81 +++-- lib/widgets/folder_pill.dart | 524 ------------------------------- lib/widgets/library_entries.dart | 16 +- test/library_entries_test.dart | 18 +- 5 files changed, 190 insertions(+), 599 deletions(-) delete mode 100644 lib/widgets/folder_pill.dart diff --git a/lib/widgets/folder_card.dart b/lib/widgets/folder_card.dart index 951a5298..b4431c03 100644 --- a/lib/widgets/folder_card.dart +++ b/lib/widgets/folder_card.dart @@ -5,6 +5,9 @@ import 'package:icarus/const/folder_icons.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/providers/library_context_menu_provider.dart'; import 'package:icarus/providers/pinned_items_provider.dart'; @@ -49,8 +52,19 @@ class FolderCardViewData { mapPeeks = _collectMapPeeks(strategies), agentTypes = _collectAgents(strategies); + /// A folder whose contents are not loaded on this device, e.g. a cloud + /// folder: only its subfolder count is known. + FolderCardViewData.summary({ + required this.folder, + required this.folderCount, + }) : strategyCount = null, + mapPeeks = const [], + agentTypes = const []; + final Folder folder; - final int strategyCount; + + /// Null when the folder's strategies are not known here. + final int? strategyCount; final int folderCount; /// Distinct map thumbnail assets, most used first, capped at 2. @@ -115,10 +129,19 @@ class FolderCard extends ConsumerStatefulWidget { const FolderCard({ super.key, required this.data, + this.store = LibraryWorkspace.local, + this.cloudRole, this.isDemo = false, }); final FolderCardViewData data; + + /// Which library the folder belongs to. My Library lists both stores, so + /// the card cannot infer this from the active workspace. + final LibraryWorkspace store; + + /// The signed-in user's role on a cloud folder; null for local folders. + final String? cloudRole; final bool isDemo; @override @@ -140,6 +163,10 @@ class _FolderCardState extends ConsumerState final DragTiltController _dragTiltController = DragTiltController(); Folder get _folder => widget.data.folder; + bool get _isCloud => widget.store == LibraryWorkspace.cloud; + + /// Shared cloud folders can be opened but only their owner reshapes them. + bool get _canManage => !_isCloud || widget.cloudRole == 'owner'; @override void initState() { @@ -209,8 +236,13 @@ class _FolderCardState extends ConsumerState String? currentParentId = _folder.parentID; while (currentParentId != null) { if (currentParentId == folderId) return true; - final parentFolder = - ref.read(folderProvider.notifier).findFolderByID(currentParentId); + final folders = ref.read(folderProvider.notifier); + final parentFolder = _isCloud + ? folders.findCloudFolderByID( + currentParentId, + ref.read(cloudAllFoldersProvider).valueOrNull ?? const [], + ) + : folders.findLocalFolderByID(currentParentId); currentParentId = parentFolder?.parentID; } return false; @@ -233,6 +265,8 @@ class _FolderCardState extends ConsumerState return DragTarget(onWillAcceptWithDetails: (details) { final item = details.data; if (widget.isDemo) return false; + if (!_canManage) return false; + if (item.store != widget.store) return false; if (item is FolderItem) { return item.folder.id != id && !_isParentFolder(item.folder.id); } @@ -283,13 +317,19 @@ class _FolderCardState extends ConsumerState } if (item is StrategyItem) { - await ref - .read(strategyProvider.notifier) - .moveToFolder(strategyID: item.strategy!.id, parentID: _folder.id); + await ref.read(strategyProvider.notifier).moveToFolder( + strategyID: item.strategyId, + parentID: _folder.id, + source: item.strategy == null + ? StrategySource.cloud + : StrategySource.local, + ); } else if (item is FolderItem) { - await ref - .read(folderProvider.notifier) - .moveToFolder(folderID: item.folder.id, parentID: _folder.id); + await ref.read(folderProvider.notifier).moveToFolder( + folderID: item.folder.id, + parentID: _folder.id, + workspace: widget.store, + ); } }, builder: (context, candidateData, rejectedData) { final isPinnedDropTarget = candidateData.any( @@ -314,7 +354,7 @@ class _FolderCardState extends ConsumerState dragAnchorStrategy: pointerDragAnchorStrategy, onDragUpdate: (details) => _dragTiltController.addDelta(details.delta.dx), - data: FolderItem(_folder, store: LibraryWorkspace.local), + data: FolderItem(_folder, store: widget.store), child: MouseRegion( onEnter: (_) { _isHovered = true; @@ -331,7 +371,10 @@ class _FolderCardState extends ConsumerState child: GestureDetector( onTap: () { if (widget.isDemo) return; - ref.read(folderProvider.notifier).updateID(_folder.id); + ref.read(folderProvider.notifier).openFolder( + folderId: _folder.id, + store: widget.store, + ); }, child: AnimatedBuilder( animation: _open, @@ -569,10 +612,11 @@ class _FolderCardState extends ConsumerState Expanded( child: agents.isEmpty ? Text( - widget.data.strategyCount == 0 - ? 'Empty' - : '${widget.data.strategyCount} ' - 'strateg${widget.data.strategyCount == 1 ? 'y' : 'ies'}', + switch (widget.data.strategyCount) { + null => '', + 0 => 'Empty', + final count => '$count strateg${count == 1 ? 'y' : 'ies'}', + }, style: TextStyle( color: Colors.white.withValues(alpha: 0.55), fontSize: 11, @@ -581,10 +625,11 @@ class _FolderCardState extends ConsumerState ) : _AgentComposition(agents: agents), ), - if (widget.data.strategyCount > 0 || widget.data.folderCount > 0) ...[ + if ((widget.data.strategyCount ?? 0) > 0 || + widget.data.folderCount > 0) ...[ const SizedBox(width: 6), _CountBadge( - strategyCount: widget.data.strategyCount, + strategyCount: widget.data.strategyCount ?? 0, folderCount: widget.data.folderCount, ), ], @@ -650,18 +695,39 @@ class _FolderCardState extends ConsumerState ShadContextMenuItem( leading: const Icon(LucideIcons.pencil), child: const Text('Edit'), - onPressed: () async { - _closeMenus(); - if (widget.isDemo) return; - await showDialog( - context: context, - builder: (context) { - return FolderEditDialog( - folder: _folder, store: LibraryWorkspace.local); - }, - ); - }, + enabled: _canManage, + onPressed: !_canManage + ? null + : () async { + _closeMenus(); + if (widget.isDemo) return; + await showDialog( + context: context, + builder: (context) { + return FolderEditDialog( + folder: _folder, + store: widget.store, + ); + }, + ); + }, ), + if (_isCloud && widget.cloudRole == 'owner') + ShadContextMenuItem( + leading: const Icon(LucideIcons.link2), + child: const Text('Share'), + onPressed: () async { + _closeMenus(); + await showShadDialog( + context: context, + builder: (_) => ShareLinksDialog( + targetType: 'folder', + targetPublicId: _folder.id, + title: _folder.name, + ), + ); + }, + ), ShadContextMenuItem( leading: const Icon(LucideIcons.upload), child: const Text('Export'), @@ -674,19 +740,21 @@ class _FolderCardState extends ConsumerState leading: Icon(LucideIcons.trash2, color: Settings.tacticalVioletTheme.destructive), child: Text('Delete', - style: - TextStyle(color: Settings.tacticalVioletTheme.destructive)), - onPressed: () async { - _closeMenus(); - if (widget.isDemo) return; - await showShadDialog( - context: context, - builder: (_) => DeleteFolderAlertDialog( - folder: _folder, - workspace: LibraryWorkspace.local, - ), - ); - }, + style: TextStyle(color: Settings.tacticalVioletTheme.destructive)), + enabled: _canManage, + onPressed: !_canManage + ? null + : () async { + _closeMenus(); + if (widget.isDemo) return; + await showShadDialog( + context: context, + builder: (_) => DeleteFolderAlertDialog( + folder: _folder, + workspace: widget.store, + ), + ); + }, ), ]; } diff --git a/lib/widgets/folder_content.dart b/lib/widgets/folder_content.dart index 602824b0..ed6e5543 100644 --- a/lib/widgets/folder_content.dart +++ b/lib/widgets/folder_content.dart @@ -22,7 +22,7 @@ import 'package:icarus/widgets/library_entries.dart'; import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; import 'package:icarus/widgets/drop_insertion_indicator.dart'; -import 'package:icarus/widgets/folder_pill.dart'; +import 'package:icarus/widgets/folder_card.dart'; import 'package:icarus/widgets/hover_dot_grid.dart'; import 'package:icarus/widgets/ica_drop_target.dart'; import 'package:icarus/widgets/strategy_tile/strategy_tile.dart'; @@ -217,6 +217,17 @@ class FolderContent extends ConsumerWidget { allFolders: allFolders, allStrategies: allStrategies, ), + card: FolderCardViewData( + folder: item, + strategies: strategiesInFolderTree( + folder: item, + allFolders: allFolders, + allStrategies: allStrategies, + ), + folderCount: allFolders + .where((folder) => folder.parentID == item.id) + .length, + ), ), ]; final strategies = [ @@ -260,7 +271,8 @@ class FolderContent extends ConsumerWidget { /// store. Widget _buildLibraryRoot(BuildContext context, WidgetRef ref) { final cloudAvailable = ref.watch(isCloudWorkspaceAvailableProvider); - final foldersAsync = cloudAvailable ? ref.watch(cloudFoldersProvider) : null; + final foldersAsync = + cloudAvailable ? ref.watch(cloudFoldersProvider) : null; final strategiesAsync = cloudAvailable ? ref.watch(cloudStrategiesProvider) : null; // Only the very first fetch shows the skeleton; dependency changes keep @@ -272,15 +284,12 @@ class FolderContent extends ConsumerWidget { if (isInitialLoading) { return const _LibraryLoadingSkeleton(key: ValueKey('cloud-loading')); } - final cloudFailed = - (foldersAsync?.hasError ?? false) || (strategiesAsync?.hasError ?? false); + final cloudFailed = (foldersAsync?.hasError ?? false) || + (strategiesAsync?.hasError ?? false); final cloudFolders = [ - for (final entry in foldersAsync?.valueOrNull ?? const []) - LibraryFolderRow( - folder: entry.folder, - store: LibraryWorkspace.cloud, - lastUpdated: entry.folder.dateCreated, - ), + for (final entry + in foldersAsync?.valueOrNull ?? const []) + _cloudFolderRow(ref, entry), ]; final cloudStrategies = [ for (final entry @@ -308,7 +317,9 @@ class FolderContent extends ConsumerWidget { ), ), acceptsIcaDrops: true, - banner: cloudFailed ? _CloudErrorBanner(onRetry: () => _retryCloud(ref)) : null, + banner: cloudFailed + ? _CloudErrorBanner(onRetry: () => _retryCloud(ref)) + : null, emptyStateKey: const ValueKey('library-empty-state'), emptyStateIcon: LucideIcons.folder, emptyStateTitle: 'Your library is empty', @@ -328,6 +339,22 @@ class FolderContent extends ConsumerWidget { ); } + LibraryFolderRow _cloudFolderRow(WidgetRef ref, CloudFolderEntry entry) { + final tree = ref.watch(cloudAllFoldersProvider).valueOrNull ?? const []; + return LibraryFolderRow( + folder: entry.folder, + store: LibraryWorkspace.cloud, + lastUpdated: entry.folder.dateCreated, + cloudRole: entry.role, + card: FolderCardViewData.summary( + folder: entry.folder, + folderCount: tree + .where((item) => item.folder.parentID == entry.folder.id) + .length, + ), + ); + } + void _retryCloud(WidgetRef ref) { ref.invalidate(cloudFolderTreeProvider); ref.invalidate(cloudStrategiesProvider); @@ -360,12 +387,9 @@ class FolderContent extends ConsumerWidget { ); } final folders = [ - for (final entry in foldersAsync.valueOrNull ?? const []) - LibraryFolderRow( - folder: entry.folder, - store: LibraryWorkspace.cloud, - lastUpdated: entry.folder.dateCreated, - ), + for (final entry + in foldersAsync.valueOrNull ?? const []) + _cloudFolderRow(ref, entry), ]; final strategies = [ for (final entry @@ -384,9 +408,8 @@ class FolderContent extends ConsumerWidget { emptyStateKey: isSharedWithMe && folder == null ? const ValueKey('shared-empty-state') : null, - emptyStateIcon: isSharedWithMe && folder == null - ? LucideIcons.users - : null, + emptyStateIcon: + isSharedWithMe && folder == null ? LucideIcons.users : null, emptyStateTitle: isSharedWithMe && folder == null ? 'Nothing shared with you yet' : 'No strategies in this folder', @@ -517,16 +540,24 @@ class FolderContent extends ConsumerWidget { if (folders.isNotEmpty) SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + // Each card carries half the gutter as drop hit area, so + // the row's padding shrinks by that much to keep x=16. + padding: const EdgeInsets.fromLTRB( + 16 - folderCardGutterOutset, + 16, + 16 - folderCardGutterOutset, + 8, + ), child: Wrap( - spacing: 10, - runSpacing: 10, + spacing: 0, + runSpacing: 14, children: [ for (final row in folders) - FolderPill( + FolderCard( key: ValueKey(row.id), - folder: row.folder, + data: row.card, store: row.store, + cloudRole: row.cloudRole, ), ], ), diff --git a/lib/widgets/folder_pill.dart b/lib/widgets/folder_pill.dart deleted file mode 100644 index db9c2bb8..00000000 --- a/lib/widgets/folder_pill.dart +++ /dev/null @@ -1,524 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:icarus/const/folder_icons.dart'; -import 'package:icarus/const/settings.dart'; -import 'package:icarus/providers/collab/remote_library_provider.dart'; -import 'package:icarus/providers/folder_provider.dart'; -import 'package:icarus/providers/library_context_menu_provider.dart'; -import 'package:icarus/providers/library_workspace_provider.dart'; -import 'package:icarus/providers/pinned_items_provider.dart'; -import 'package:icarus/providers/strategy_provider.dart'; -import 'package:icarus/strategy/strategy_import_export.dart'; -import 'package:icarus/strategy/strategy_page_models.dart'; -import 'package:icarus/widgets/dialogs/delete_folder_alert_dialog.dart'; -import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; -import 'package:icarus/widgets/drag_tilt_feedback.dart'; -import 'package:icarus/widgets/drop_insertion_indicator.dart'; -import 'package:icarus/widgets/folder_edit_dialog.dart'; -import 'package:icarus/widgets/folder_navigator.dart'; -import 'package:icarus/widgets/overflow_tooltip_text.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; - -const double _folderPillCornerRadius = 8; -const double _folderPillMenuButtonRadius = 6; - -class FolderPill extends ConsumerStatefulWidget { - const FolderPill({ - super.key, - required this.folder, - required this.store, - this.isDemo = false, - this.strategyCount, - this.folderCount, - }); - - final Folder folder; - - /// Which library the folder belongs to. My Library lists both stores, so - /// the pill cannot infer this from the active workspace. - final LibraryWorkspace store; - final bool isDemo; - final int? strategyCount; - final int? folderCount; - - @override - ConsumerState createState() => _FolderPillState(); -} - -class _FolderPillState extends ConsumerState - with SingleTickerProviderStateMixin { - late AnimationController _animationController; - late Animation _scaleAnimation; - bool _isHovered = false; - bool _isMenuButtonHovered = false; - bool _menuButtonWasOpenOnPointerDown = false; - DropInsertionSide? _pinnedDropSide; - final ShadContextMenuController _contextMenuController = - ShadContextMenuController(); - final ShadContextMenuController _rightClickMenuController = - ShadContextMenuController(); - final DragTiltController _dragTiltController = DragTiltController(); - @override - void initState() { - super.initState(); - _animationController = AnimationController( - duration: const Duration(milliseconds: 150), - vsync: this, - ); - - _scaleAnimation = Tween( - begin: 1.0, - end: 1.03, - ).animate(CurvedAnimation( - parent: _animationController, - curve: Curves.easeOut, - )); - } - - @override - void dispose() { - _animationController.dispose(); - _contextMenuController.dispose(); - _rightClickMenuController.dispose(); - super.dispose(); - } - - void _closeMenus() { - _contextMenuController.hide(); - _rightClickMenuController.hide(); - } - - void _handleMenuButtonPressed() { - if (_menuButtonWasOpenOnPointerDown) { - _menuButtonWasOpenOnPointerDown = false; - _closeMenus(); - return; - } - - dismissLibraryContextMenus(ref); - _contextMenuController.show(); - } - - Color get _folderColor => - widget.folder.customColor ?? - Folder.folderColorMap[widget.folder.color] ?? - Colors.grey; - - bool get _isCloudWorkspace => widget.store == LibraryWorkspace.cloud; - - String? get _cloudRole { - if (!_isCloudWorkspace) { - return null; - } - final allFolders = - ref.read(cloudAllFoldersProvider).valueOrNull ?? const []; - return allFolders - .where((entry) => entry.folder.id == widget.folder.id) - .map((entry) => entry.role) - .firstOrNull; - } - - bool get _canManageCloudFolder => !_isCloudWorkspace || _cloudRole == 'owner'; - - bool _isPinnedFolderReorderCandidate( - GridItem item, - Map pinned, - String id, - bool isPinned, - ) { - return item is FolderItem && - item.folder.id != id && - isPinned && - pinned.containsKey(item.folder.id); - } - - @override - Widget build(BuildContext context) { - ref.listen( - libraryContextMenuDismissalProvider, - (_, __) => _closeMenus(), - ); - - final pinned = ref.watch(pinnedItemsProvider); - final id = widget.folder.id; - final isPinned = pinned.containsKey(id); - - return Draggable( - feedback: TiltDragFeedback( - controller: _dragTiltController, - opacity: 0.9, - child: _buildDragFeedback(), - ), - dragAnchorStrategy: pointerDragAnchorStrategy, - onDragUpdate: (details) => _dragTiltController.addDelta(details.delta.dx), - data: FolderItem(widget.folder, store: widget.store), - child: DragTarget( - onWillAcceptWithDetails: (details) { - final item = details.data; - if (widget.isDemo) return false; - if (!_canManageCloudFolder) return false; - if (item.store != widget.store) return false; - if (item is FolderItem) { - return item.folder.id != id && !_isParentFolder(item.folder.id); - } - return true; - }, - onMove: (details) { - final item = details.data; - final nextSide = - _isPinnedFolderReorderCandidate(item, pinned, id, isPinned) - ? resolveDropInsertionSide( - context: context, - globalOffset: details.offset, - current: _pinnedDropSide, - ) - : null; - if (nextSide != _pinnedDropSide) { - setState(() => _pinnedDropSide = nextSide); - } - }, - onLeave: (_) { - if (_pinnedDropSide != null) { - setState(() => _pinnedDropSide = null); - } - }, - onAcceptWithDetails: (details) async { - if (widget.isDemo) return; - final item = details.data; - if (item is FolderItem && - item.folder.id != id && - isPinned && - pinned.containsKey(item.folder.id)) { - final insertionSide = _pinnedDropSide ?? - resolveDropInsertionSide( - context: context, - globalOffset: details.offset, - ); - if (mounted) { - setState(() => _pinnedDropSide = null); - } - if (insertionSide == null) return; - - await ref.read(pinnedItemsProvider.notifier).movePin( - id: item.folder.id, - targetId: id, - insertAfterTarget: insertionSide == DropInsertionSide.after, - ); - return; - } - - if (item is StrategyItem) { - await ref.read(strategyProvider.notifier).moveToFolder( - strategyID: item.strategyId, - parentID: widget.folder.id, - source: item.strategy == null - ? StrategySource.cloud - : StrategySource.local, - ); - } else if (item is FolderItem) { - await ref.read(folderProvider.notifier).moveToFolder( - folderID: item.folder.id, - parentID: widget.folder.id, - workspace: widget.store, - ); - } - }, - builder: (context, candidateData, rejectedData) { - final isPinnedDropTarget = candidateData.any( - (item) => - item is FolderItem && - item.folder.id != id && - isPinned && - pinned.containsKey(item.folder.id), - ); - final isDropTarget = candidateData.isNotEmpty; - final isMoveIntoFolderTarget = isDropTarget && !isPinnedDropTarget; - final isHoverActive = _isHovered && !isPinnedDropTarget; - return MouseRegion( - onEnter: (_) { - setState(() => _isHovered = true); - _animationController.forward(); - }, - onExit: (_) { - setState(() => _isHovered = false); - _animationController.reverse(); - }, - cursor: SystemMouseCursors.click, - child: ShadContextMenuRegion( - controller: _rightClickMenuController, - items: _buildMenuItems(isPinned: isPinned), - child: GestureDetector( - onTap: () { - if (widget.isDemo) return; - ref.read(folderProvider.notifier).openFolder( - folderId: widget.folder.id, - store: widget.store, - ); - }, - child: AnimatedBuilder( - animation: _scaleAnimation, - builder: (context, child) { - final dropSide = _pinnedDropSide; - final slotKey = dropSide == null - ? null - : dropInsertionSlotKey( - itemId: id, - side: dropSide, - pinnedOrder: pinnedIdsInManualOrder(pinned), - ); - return Transform.scale( - scale: isPinnedDropTarget ? 1 : _scaleAnimation.value, - child: Stack( - clipBehavior: Clip.none, - children: [ - Container( - height: 44, - padding: const EdgeInsets.only(left: 14, right: 6), - decoration: BoxDecoration( - color: _folderColor, - borderRadius: BorderRadius.circular( - _folderPillCornerRadius), - border: Border.all( - color: isMoveIntoFolderTarget - ? Colors.white - : (isHoverActive - ? Colors.white.withValues(alpha: 0.5) - : Colors.white.withValues(alpha: 0.15)), - width: isMoveIntoFolderTarget ? 2 : 1, - ), - boxShadow: [ - BoxShadow( - color: _folderColor.withValues(alpha: 0.3), - blurRadius: isHoverActive ? 8 : 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - FolderIconView( - iconId: widget.folder.iconId, - color: Colors.white, - size: 20, - ), - const SizedBox(width: 10), - ConstrainedBox( - constraints: - const BoxConstraints(maxWidth: 140), - child: OverflowTooltipText( - widget.folder.name, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ), - if (isPinned) ...[ - const SizedBox(width: 6), - Icon( - LucideIcons.pin, - color: Colors.white.withValues(alpha: 0.78), - size: 14, - ), - ], - const SizedBox(width: 4), - _buildMenuButton(isPinned: isPinned), - ], - ), - ), - if (dropSide != null && slotKey != null) - Positioned.fill( - child: DropInsertionIndicator( - key: ValueKey(slotKey), - slotKey: slotKey, - side: dropSide, - gap: 18, - ), - ), - ], - ), - ); - }, - ), - ), - ), - ); - }, - ), - ); - } - - Widget _buildMenuButton({required bool isPinned}) { - final backgroundAlpha = _isMenuButtonHovered ? 0.16 : 0.04; - final iconAlpha = _isMenuButtonHovered ? 0.96 : 0.74; - - return ShadContextMenuRegion( - controller: _contextMenuController, - items: _buildMenuItems(isPinned: isPinned), - child: MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (_) => setState(() => _isMenuButtonHovered = true), - onExit: (_) => setState(() => _isMenuButtonHovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - curve: Curves.easeOutCubic, - width: 28, - height: 28, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: backgroundAlpha), - borderRadius: BorderRadius.circular(_folderPillMenuButtonRadius), - ), - child: Listener( - onPointerDown: (_) { - _menuButtonWasOpenOnPointerDown = _contextMenuController.isOpen; - }, - child: InkWell( - borderRadius: BorderRadius.circular(_folderPillMenuButtonRadius), - mouseCursor: SystemMouseCursors.click, - hoverColor: Colors.transparent, - splashColor: Colors.white.withValues(alpha: 0.12), - highlightColor: Colors.white.withValues(alpha: 0.08), - onTap: _handleMenuButtonPressed, - child: Icon( - LucideIcons.ellipsisVertical, - color: Colors.white.withValues(alpha: iconAlpha), - size: 18, - ), - ), - ), - ), - ), - ); - } - - List _buildMenuItems({required bool isPinned}) { - final id = widget.folder.id; - return [ - ShadContextMenuItem( - leading: Icon(isPinned ? LucideIcons.pinOff : LucideIcons.pin), - child: Text(isPinned ? 'Unpin' : 'Pin'), - onPressed: () { - _closeMenus(); - if (widget.isDemo) return; - ref.read(pinnedItemsProvider.notifier).togglePin(id); - }, - ), - ShadContextMenuItem( - leading: const Icon(LucideIcons.pencil), - child: const Text('Edit'), - onPressed: !_canManageCloudFolder - ? null - : () async { - _closeMenus(); - if (widget.isDemo) return; - await showDialog( - context: context, - builder: (context) { - return FolderEditDialog( - folder: widget.folder, - store: widget.store, - ); - }, - ); - }, - ), - if (_isCloudWorkspace && _cloudRole == 'owner') - ShadContextMenuItem( - leading: const Icon(LucideIcons.link2), - child: const Text('Share'), - onPressed: () async { - await showShadDialog( - context: context, - builder: (_) => ShareLinksDialog( - targetType: 'folder', - targetPublicId: widget.folder.id, - title: widget.folder.name, - ), - ); - }, - ), - ShadContextMenuItem( - leading: const Icon(LucideIcons.upload), - child: const Text('Export'), - onPressed: () async { - _closeMenus(); - await StrategyImportExportService(ref).exportFolder(widget.folder.id); - }, - ), - ShadContextMenuItem( - leading: Icon(LucideIcons.trash2, - color: Settings.tacticalVioletTheme.destructive), - child: Text('Delete', - style: - TextStyle(color: Settings.tacticalVioletTheme.destructive)), - onPressed: !_canManageCloudFolder - ? null - : () async { - _closeMenus(); - if (widget.isDemo) return; - await showShadDialog( - context: context, - builder: (_) => DeleteFolderAlertDialog( - folder: widget.folder, - workspace: widget.store, - ), - ); - }, - ), - ]; - } - - Widget _buildDragFeedback() { - return Container( - height: 44, - padding: const EdgeInsets.symmetric(horizontal: 14), - decoration: BoxDecoration( - color: _folderColor, - borderRadius: BorderRadius.circular(_folderPillCornerRadius), - border: Border.all(color: Colors.white, width: 2), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - FolderIconView( - iconId: widget.folder.iconId, - color: Colors.white, - size: 20, - ), - const SizedBox(width: 10), - Text( - widget.folder.name, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ); - } - - bool _isParentFolder(String folderId) { - String? currentParentId = widget.folder.parentID; - while (currentParentId != null) { - if (currentParentId == folderId) return true; - final parentFolder = widget.store == LibraryWorkspace.local - ? ref - .read(folderProvider.notifier) - .findLocalFolderByID(currentParentId) - : ref.read(folderProvider.notifier).findCloudFolderByID( - currentParentId, - ref.read(cloudAllFoldersProvider).valueOrNull ?? const [], - ); - currentParentId = parentFolder?.parentID; - } - return false; - } -} - -extension on Iterable { - String? get firstOrNull => isEmpty ? null : first; -} diff --git a/lib/widgets/library_entries.dart b/lib/widgets/library_entries.dart index 58374da8..52ef4932 100644 --- a/lib/widgets/library_entries.dart +++ b/lib/widgets/library_entries.dart @@ -1,6 +1,7 @@ import 'package:icarus/collab/cloud_library_models.dart'; import 'package:icarus/domain/folder.dart'; import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/widgets/folder_card.dart'; import 'package:icarus/providers/strategy_filter_provider.dart'; import 'package:icarus/strategy/strategy_models.dart'; @@ -11,11 +12,20 @@ class LibraryFolderRow { required this.folder, required this.store, required this.lastUpdated, + required this.card, + this.cloudRole, }); final Folder folder; final LibraryWorkspace store; + /// What the folder card shows: contents for local folders, the subfolder + /// count alone for cloud folders (their strategies load per folder). + final FolderCardViewData card; + + /// The signed-in user's role on a cloud folder; null for local folders. + final String? cloudRole; + /// Newest edit inside the folder tree, used for the "Date updated" sort. /// Cloud folders do not carry this yet and fall back to their creation date. final DateTime lastUpdated; @@ -25,7 +35,8 @@ class LibraryFolderRow { /// One strategy in the library grid, from either store. class LibraryStrategyRow { - LibraryStrategyRow.local(StrategyData strategy, {required this.showDeviceBadge}) + LibraryStrategyRow.local(StrategyData strategy, + {required this.showDeviceBadge}) : local = strategy, cloud = null; @@ -84,7 +95,8 @@ List sortLibraryFolders( final result = switch (filter.sortBy) { SortBy.alphabetical => a.folder.name.toLowerCase().compareTo(b.folder.name.toLowerCase()), - SortBy.dateCreated => a.folder.dateCreated.compareTo(b.folder.dateCreated), + SortBy.dateCreated => + a.folder.dateCreated.compareTo(b.folder.dateCreated), SortBy.dateUpdated => a.lastUpdated.compareTo(b.lastUpdated), }; if (result != 0) return direction * result; diff --git a/test/library_entries_test.dart b/test/library_entries_test.dart index 3adacd3e..2dd4c65c 100644 --- a/test/library_entries_test.dart +++ b/test/library_entries_test.dart @@ -5,6 +5,7 @@ import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/providers/strategy_filter_provider.dart'; import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/widgets/folder_card.dart'; import 'package:icarus/widgets/library_entries.dart'; void main() { @@ -17,7 +18,8 @@ void main() { _localStrategy('local-only', 'Mine'), showDeviceBadge: true, ); - final cloud = LibraryStrategyRow.cloud(_cloudStrategy('shared-id', 'Cloud')); + final cloud = + LibraryStrategyRow.cloud(_cloudStrategy('shared-id', 'Cloud')); final merged = mergeLibraryStrategies( local: [local, onlyLocal], @@ -100,14 +102,16 @@ CloudStrategyEntry _cloudStrategy(String id, String name, {DateTime? created}) { } LibraryFolderRow _folderRow(String id, LibraryWorkspace store) { + final folder = Folder( + name: id, + id: id, + dateCreated: DateTime(2024, 1, 1), + color: FolderColor.blue, + ); return LibraryFolderRow( - folder: Folder( - name: id, - id: id, - dateCreated: DateTime(2024, 1, 1), - color: FolderColor.blue, - ), + folder: folder, store: store, lastUpdated: DateTime(2024, 1, 1), + card: FolderCardViewData.summary(folder: folder, folderCount: 0), ); } From 41440623eb5da48bfcb69a9894437122a1fda728 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:49:53 -0400 Subject: [PATCH 14/27] Put the library breadcrumb on a card Co-Authored-By: Claude Fable 5.1 --- lib/widgets/folder_content.dart | 2 +- lib/widgets/library_breadcrumb.dart | 89 +++++++++++++++++------------ 2 files changed, 53 insertions(+), 38 deletions(-) diff --git a/lib/widgets/folder_content.dart b/lib/widgets/folder_content.dart index ed6e5543..3ad4571e 100644 --- a/lib/widgets/folder_content.dart +++ b/lib/widgets/folder_content.dart @@ -626,7 +626,7 @@ class FolderContent extends ConsumerWidget { children: [ if (currentFolder != null) Padding( - padding: const EdgeInsets.fromLTRB(12, 10, 16, 0), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: LibraryBreadcrumb(folder: currentFolder), ), if (banner != null) banner, diff --git a/lib/widgets/library_breadcrumb.dart b/lib/widgets/library_breadcrumb.dart index c265751f..72a476b0 100644 --- a/lib/widgets/library_breadcrumb.dart +++ b/lib/widgets/library_breadcrumb.dart @@ -34,47 +34,62 @@ class LibraryBreadcrumb extends ConsumerWidget { } } - return Row( - children: [ - ShadIconButton.ghost( - width: 30, - height: 30, - foregroundColor: Settings.tacticalVioletTheme.mutedForeground, - onPressed: () { - if (parent == null) { - goToRoot(); - } else { - ref.read(folderProvider.notifier).updateID(parent.id); - } - }, - icon: const Icon(LucideIcons.chevronLeft, size: 20), + // Same card as the editor toolbar, so the path reads as hardware on the + // bench instead of text floating on the dot grid. + return Align( + alignment: Alignment.centerLeft, + child: Container( + height: 36, + padding: const EdgeInsets.fromLTRB(4, 0, 12, 0), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.card, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Settings.tacticalVioletTheme.border), ), - const SizedBox(width: 4), - Expanded( - child: ShadBreadcrumb( - lastItemTextColor: Settings.tacticalVioletTheme.foreground, - textStyle: ShadTheme.of(context).textTheme.lead, - children: [ - FolderTab( - folder: null, - label: - tab == LibraryTab.shared ? 'Shared with Me' : 'My Library', - store: store, - onOpen: goToRoot, - ), - for (int i = 0; i < pathFolders.length; i++) + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ShadIconButton.ghost( + width: 28, + height: 28, + foregroundColor: Settings.tacticalVioletTheme.mutedForeground, + hoverForegroundColor: Settings.tacticalVioletTheme.foreground, + onPressed: () { + if (parent == null) { + goToRoot(); + } else { + ref.read(folderProvider.notifier).updateID(parent.id); + } + }, + icon: const Icon(LucideIcons.chevronLeft300, size: 18), + ), + const SizedBox(width: 4), + ShadBreadcrumb( + lastItemTextColor: Settings.tacticalVioletTheme.foreground, + textStyle: ShadTheme.of(context).textTheme.small, + children: [ FolderTab( - folder: pathFolders[i], + folder: null, + label: tab == LibraryTab.shared + ? 'Shared with Me' + : 'My Library', store: store, - isActive: i == pathFolders.length - 1, - onOpen: () => ref - .read(folderProvider.notifier) - .updateID(pathFolders[i].id), + onOpen: goToRoot, ), - ], - ), + for (int i = 0; i < pathFolders.length; i++) + FolderTab( + folder: pathFolders[i], + store: store, + isActive: i == pathFolders.length - 1, + onOpen: () => ref + .read(folderProvider.notifier) + .updateID(pathFolders[i].id), + ), + ], + ), + ], ), - ], + ), ); } @@ -126,7 +141,7 @@ class FolderTab extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return ShadBreadcrumbLink( - textStyle: ShadTheme.of(context).textTheme.lead, + textStyle: ShadTheme.of(context).textTheme.small, normalColor: isActive ? Settings.tacticalVioletTheme.foreground : null, onPressed: onOpen, child: DragTarget( From c01b3cc3f1d97eb08d5682eb19d719e507d24c73 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:01:44 -0400 Subject: [PATCH 15/27] Summarise cloud folders on the server so their cards show maps, agents, and counts The client only streams the strategies of the folder it is looking at, so a cloud folder card at the root had nothing to draw. The folder tree query now returns, per folder and across its subtree, the strategy count, the two most-used maps, and the agents in play. Counts and maps come from the strategies table. Agents live inside elements, so ops.applyBatch keeps a derived strategyAgentSummaries row current after any content op; the strategy row itself stays untouched, as the record-scoped write contract requires. A one-off internal backfill fills the summary for strategies written before this landed. Co-Authored-By: Claude Fable 5.1 --- convex/_generated/api.d.ts | 2 + convex/folders.test.ts | 105 ++++++++++++++++++ convex/folders.ts | 90 +++++++++++++++ convex/function_spec.json | 38 +++++++ convex/lib/publicValidators.ts | 6 + convex/lib/strategyAgentSummary.ts | 94 ++++++++++++++++ convex/maintenance.ts | 15 +++ convex/ops.ts | 9 ++ convex/schema.ts | 7 ++ convex/strategies.ts | 2 + lib/collab/cloud_library_models.dart | 13 ++- lib/collab/convex_strategy_repository.dart | 26 +++++ lib/collab/generated/convex_models.dart | 48 ++++++++ lib/widgets/folder_card.dart | 14 ++- lib/widgets/folder_content.dart | 3 + test/convex_generated_client_test.dart | 3 + .../remote_library_provider_test.dart | 14 +++ 17 files changed, 483 insertions(+), 6 deletions(-) create mode 100644 convex/lib/strategyAgentSummary.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 00ff5ebe..ff2dbf2a 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -25,6 +25,7 @@ import type * as lib_payloadValidators from "../lib/payloadValidators.js"; import type * as lib_publicValidators from "../lib/publicValidators.js"; import type * as lib_r2 from "../lib/r2.js"; import type * as lib_snapshotSerialization from "../lib/snapshotSerialization.js"; +import type * as lib_strategyAgentSummary from "../lib/strategyAgentSummary.js"; import type * as lineups from "../lineups.js"; import type * as maintenance from "../maintenance.js"; import type * as ops from "../ops.js"; @@ -59,6 +60,7 @@ declare const fullApi: ApiFromModules<{ "lib/publicValidators": typeof lib_publicValidators; "lib/r2": typeof lib_r2; "lib/snapshotSerialization": typeof lib_snapshotSerialization; + "lib/strategyAgentSummary": typeof lib_strategyAgentSummary; lineups: typeof lineups; maintenance: typeof maintenance; ops: typeof ops; diff --git a/convex/folders.test.ts b/convex/folders.test.ts index 87e3a331..72b9d079 100644 --- a/convex/folders.test.ts +++ b/convex/folders.test.ts @@ -100,3 +100,108 @@ test("folder move rejects a descendant parent without changing the tree", async await expect(parentPublicId(t, "child")).resolves.toBe("root"); await expect(parentPublicId(t, "grandchild")).resolves.toBe("child"); }); + +const listFolderTree = makeFunctionReference<"query">("folders:listTree"); +const createStrategy = makeFunctionReference<"mutation">( + "strategies:createWithInitialPage", +); +const applyBatch = makeFunctionReference<"mutation">("ops:applyBatch"); +const deleteStrategy = makeFunctionReference<"mutation">("strategies:delete"); + +async function seedStrategy( + owner: Harness, + publicId: string, + folderPublicId: string, + mapData: string, +) { + await owner.mutation(createStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + publicId, + name: publicId, + mapData, + folderPublicId, + initialPagePublicId: `${publicId}-page`, + initialPageName: "Page 1", + initialPageIsAttack: true, + initialPageSettings: { + agentSize: 48, + abilitySize: 32, + useNeutralTeamColors: false, + }, + }); +} + +async function placeAgent( + owner: Harness, + strategyPublicId: string, + elementPublicId: string, + agentType: string, +) { + await owner.mutation(applyBatch, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + clientId: "client-a", + ops: [ + { + opId: `${elementPublicId}-add`, + type: "element.add", + elementPublicId, + pagePublicId: `${strategyPublicId}-page`, + payload: { + kind: "agent", + payloadVersion: 1, + data: { type: agentType, position: { x: 0.5, y: 0.5 } }, + }, + sortIndex: 0, + }, + ], + }); +} + +test("folder tree summarises strategies, maps, and agents across the subtree", async () => { + const { owner } = await createHarness(); + await seedFolder(owner, "root"); + await seedFolder(owner, "child", "root"); + await seedStrategy(owner, "s-root", "root", "ascent"); + await seedStrategy(owner, "s-child-1", "child", "haven"); + await seedStrategy(owner, "s-child-2", "child", "haven"); + await placeAgent(owner, "s-root", "e1", "jett"); + await placeAgent(owner, "s-child-1", "e2", "sova"); + await placeAgent(owner, "s-child-2", "e3", "sova"); + + const tree = (await owner.query(listFolderTree, { scope: "owned" })) as Array< + Record + >; + const byId = new Map(tree.map((entry) => [entry.publicId, entry])); + expect(byId.get("child")).toMatchObject({ + strategyCount: 2, + mapPeeks: ["haven"], + agentTypes: ["sova"], + }); + expect(byId.get("root")).toMatchObject({ + strategyCount: 3, + mapPeeks: ["haven", "ascent"], + agentTypes: ["sova", "jett"], + }); +}); + +test("deleting a strategy drops it from the folder summary", async () => { + const { owner } = await createHarness(); + await seedFolder(owner, "root"); + await seedStrategy(owner, "s-root", "root", "ascent"); + await placeAgent(owner, "s-root", "e1", "jett"); + await owner.mutation(deleteStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId: "s-root", + expectedRevision: 0, + }); + + const tree = (await owner.query(listFolderTree, { scope: "owned" })) as Array< + Record + >; + expect(tree[0]).toMatchObject({ + strategyCount: 0, + mapPeeks: [], + agentTypes: [], + }); +}); diff --git a/convex/folders.ts b/convex/folders.ts index d6386902..14c0758b 100644 --- a/convex/folders.ts +++ b/convex/folders.ts @@ -8,6 +8,7 @@ import { requireCurrentUser, } from "./lib/auth"; import { getFolderByPublicId } from "./lib/entities"; +import { readStrategyAgentTypes } from "./lib/strategyAgentSummary"; import { assertSupportedCloudProtocol, cloudProtocolArgs, @@ -285,6 +286,10 @@ export const listTree = query({ const folderLookup = new Map( accessible.map(({ folder }) => [folder._id, folder]), ); + const summaries = await summariseFolderTrees( + ctx, + accessible.map(({ folder }) => folder), + ); return accessible .sort((a, b) => a.folder.createdAt - b.folder.createdAt) @@ -304,10 +309,95 @@ export const listTree = query({ createdAt: folder.createdAt, updatedAt: folder.updatedAt, role, + ...summaries.get(folder._id)!, })); }, }); +type FolderTreeSummary = { + strategyCount: number; + mapPeeks: string[]; + agentTypes: string[]; +}; + +/// One pass over the strategies of every listed folder, then counts roll up +/// from each folder into its ancestors so a parent summarises its whole +/// subtree. Folders outside the list (not accessible) contribute nothing. +async function summariseFolderTrees( + ctx: AnyCtx, + folders: Doc<"folders">[], +): Promise, FolderTreeSummary>> { + const listed = new Set(folders.map((folder) => folder._id)); + const strategyCounts = new Map, number>(); + const mapCounts = new Map, Map>(); + const agentCounts = new Map, Map>(); + for (const folder of folders) { + strategyCounts.set(folder._id, 0); + mapCounts.set(folder._id, new Map()); + agentCounts.set(folder._id, new Map()); + } + const bump = (counts: Map, key: string) => + counts.set(key, (counts.get(key) ?? 0) + 1); + + for (const folder of folders) { + const strategies = await ctx.db + .query("strategies") + .withIndex("by_folderId", (q) => q.eq("folderId", folder._id)) + .collect(); + const agentTypesSeen = new Map(); + for (const strategy of strategies) { + bump(mapCounts.get(folder._id)!, strategy.mapData); + for (const type of await readStrategyAgentTypes(ctx, strategy._id)) { + bump(agentTypesSeen, type); + } + } + strategyCounts.set(folder._id, strategies.length); + agentCounts.set(folder._id, agentTypesSeen); + } + + // Roll every folder's own counts up through its listed ancestors. + const merged = new Map, FolderTreeSummary>(); + const totals = new Map< + Id<"folders">, + { strategies: number; maps: Map; agents: Map } + >(); + for (const folder of folders) { + totals.set(folder._id, { strategies: 0, maps: new Map(), agents: new Map() }); + } + const mergeCounts = (into: Map, from: Map) => { + for (const [key, count] of from) into.set(key, (into.get(key) ?? 0) + count); + }; + for (const folder of folders) { + let current: Doc<"folders"> | undefined = folder; + const visited = new Set>(); + while (current !== undefined && !visited.has(current._id)) { + visited.add(current._id); + const total = totals.get(current._id)!; + total.strategies += strategyCounts.get(folder._id)!; + mergeCounts(total.maps, mapCounts.get(folder._id)!); + mergeCounts(total.agents, agentCounts.get(folder._id)!); + const parentId: Id<"folders"> | undefined = current.parentFolderId; + current = + parentId !== undefined && listed.has(parentId) + ? folders.find((candidate) => candidate._id === parentId) + : undefined; + } + } + const ranked = (counts: Map) => + [...counts.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([key]) => key); + for (const folder of folders) { + const total = totals.get(folder._id)!; + merged.set(folder._id, { + strategyCount: total.strategies, + mapPeeks: ranked(total.maps).slice(0, 2), + agentTypes: ranked(total.agents), + }); + } + return merged; +} + export const move = mutation({ args: { ...cloudProtocolArgs, diff --git a/convex/function_spec.json b/convex/function_spec.json index d342d722..946abb2a 100644 --- a/convex/function_spec.json +++ b/convex/function_spec.json @@ -39531,6 +39531,15 @@ "value": { "type": "object", "value": { + "agentTypes": { + "fieldType": { + "type": "array", + "value": { + "type": "string" + } + }, + "optional": false + }, "color": { "fieldType": { "type": "union", @@ -39621,6 +39630,15 @@ }, "optional": false }, + "mapPeeks": { + "fieldType": { + "type": "array", + "value": { + "type": "string" + } + }, + "optional": false + }, "name": { "fieldType": { "type": "string" @@ -39667,6 +39685,12 @@ }, "optional": false }, + "strategyCount": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "updatedAt": { "fieldType": { "type": "number" @@ -47916,6 +47940,20 @@ "kind": "public" } }, + { + "args": { + "type": "object", + "value": {} + }, + "functionType": "Mutation", + "identifier": "maintenance.js:backfillStrategyAgentSummaries", + "returns": { + "type": "number" + }, + "visibility": { + "kind": "internal" + } + }, { "args": { "type": "object", diff --git a/convex/lib/publicValidators.ts b/convex/lib/publicValidators.ts index f85e7daf..ceea6363 100644 --- a/convex/lib/publicValidators.ts +++ b/convex/lib/publicValidators.ts @@ -78,6 +78,12 @@ export const folderSummaryValidator = v.object({ createdAt: v.number(), updatedAt: v.number(), role: accessRoleValidator, + // What the folder holds, including every subfolder: how many strategies, + // the two most-used maps (wire names, most used first), and the agents in + // play ordered by how many strategies use them. + strategyCount: v.number(), + mapPeeks: v.array(v.string()), + agentTypes: v.array(v.string()), }); export const pageDescriptorValidator = v.object({ diff --git a/convex/lib/strategyAgentSummary.ts b/convex/lib/strategyAgentSummary.ts new file mode 100644 index 00000000..8123f016 --- /dev/null +++ b/convex/lib/strategyAgentSummary.ts @@ -0,0 +1,94 @@ +import type { Id } from "../_generated/dataModel"; +import type { MutationCtx, QueryCtx } from "../_generated/server"; + +/// Reads the agent type of one agent element or lineup group payload. The +/// client stores the agent enum name under `type` (an element) or under +/// `agent.type` (a lineup group); anything else is not an agent. +function agentTypeOf(data: unknown): string | null { + if (typeof data !== "object" || data === null) return null; + const record = data as Record; + const direct = record.type; + if (typeof direct === "string" && direct.length > 0) return direct; + const agent = record.agent; + if (typeof agent === "object" && agent !== null) { + const nested = (agent as Record).type; + if (typeof nested === "string" && nested.length > 0) return nested; + } + return null; +} + +/// Recomputes which agents a strategy uses, from its live agent elements and +/// lineup groups, and stores the answer in its own row. Content ops never +/// touch the strategy row itself; the summary is derived data that the +/// folder tree reads without scanning elements. +export async function refreshStrategyAgentSummary( + ctx: MutationCtx, + strategyId: Id<"strategies">, +): Promise { + const counts = new Map(); + const bump = (type: string | null) => { + if (type === null) return; + counts.set(type, (counts.get(type) ?? 0) + 1); + }; + const elements = await ctx.db + .query("elements") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategyId)) + .collect(); + for (const element of elements) { + if (element.deleted || element.payload.kind !== "agent") continue; + bump(agentTypeOf(element.payload.data)); + } + const lineups = await ctx.db + .query("lineups") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategyId)) + .collect(); + for (const lineup of lineups) { + if (lineup.deleted) continue; + bump(agentTypeOf(lineup.payload.data)); + } + const agentTypes = [...counts.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([type]) => type); + + const existing = await ctx.db + .query("strategyAgentSummaries") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategyId)) + .unique(); + const now = Date.now(); + if (existing === null) { + if (agentTypes.length === 0) return; + await ctx.db.insert("strategyAgentSummaries", { + strategyId, + agentTypes, + updatedAt: now, + }); + return; + } + const unchanged = + existing.agentTypes.length === agentTypes.length && + existing.agentTypes.every((type, index) => type === agentTypes[index]); + if (unchanged) return; + await ctx.db.patch(existing._id, { agentTypes, updatedAt: now }); +} + +export async function deleteStrategyAgentSummary( + ctx: MutationCtx, + strategyId: Id<"strategies">, +): Promise { + const existing = await ctx.db + .query("strategyAgentSummaries") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategyId)) + .unique(); + if (existing !== null) await ctx.db.delete(existing._id); +} + +export async function readStrategyAgentTypes( + ctx: QueryCtx | MutationCtx, + strategyId: Id<"strategies">, +): Promise { + const summary = await ctx.db + .query("strategyAgentSummaries") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategyId)) + .unique(); + return summary?.agentTypes ?? []; +} diff --git a/convex/maintenance.ts b/convex/maintenance.ts index c27839a0..55a14073 100644 --- a/convex/maintenance.ts +++ b/convex/maintenance.ts @@ -6,6 +6,7 @@ import { collectAssetIdsFromLineupPayload, } from "./lib/imageAssets"; import { captureDeletedPageImageAssets } from "./images"; +import { refreshStrategyAgentSummary } from "./lib/strategyAgentSummary"; const MAINTENANCE_BATCH_SIZE = 200; const DAYS_30_MS = 30 * 24 * 60 * 60 * 1000; @@ -140,3 +141,17 @@ export const purgeOldTombstones = internalMutation({ } }, }); + +/// One-off after the agent summary table shipped: every strategy written +/// before it needs its summary computed once. Safe to re-run. +export const backfillStrategyAgentSummaries = internalMutation({ + args: {}, + returns: v.number(), + handler: async (ctx) => { + const strategies = await ctx.db.query("strategies").collect(); + for (const strategy of strategies) { + await refreshStrategyAgentSummary(ctx, strategy._id); + } + return strategies.length; + }, +}); diff --git a/convex/ops.ts b/convex/ops.ts index 5de8952e..70bbd8e0 100644 --- a/convex/ops.ts +++ b/convex/ops.ts @@ -2,6 +2,7 @@ import { mutation, type MutationCtx } from "./_generated/server"; import { ConvexError, v, type Infer } from "convex/values"; import type { Doc, Id } from "./_generated/dataModel"; import { assertStrategyRole } from "./lib/auth"; +import { refreshStrategyAgentSummary } from "./lib/strategyAgentSummary"; import { clampPageIndex, getStrategyByPublicId, @@ -1323,6 +1324,7 @@ export const applyBatch = mutation({ await assertStrategyRole(ctx, strategy, "editor"); const results: PublicOperationResult[] = []; let acceptedStrategyBatchBaseRevision: number | undefined; + let contentChanged = false; // Outcomes are per operation: accepted changes and visible rejections are // committed together by this single Convex transaction. One stale op must @@ -1401,6 +1403,9 @@ export const applyBatch = mutation({ } else { result = await applyLineupOp(ctx, strategy, op); } + if (result.status === "ack" && op.entityType !== "strategy") { + contentChanged = true; + } } catch (error) { if (!(error instanceof ConvexError)) throw error; const rawCode = @@ -1458,6 +1463,10 @@ export const applyBatch = mutation({ results.push(publicResult); } + if (contentChanged) { + await refreshStrategyAgentSummary(ctx, strategy._id); + } + return { strategyPublicId: strategy.publicId, results }; }, }); diff --git a/convex/schema.ts b/convex/schema.ts index 47eb17f7..b9908d50 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -200,6 +200,13 @@ export default defineSchema({ .index("by_uploadStatus_and_updatedAt", ["uploadStatus", "updatedAt"]) .index("by_storageId", ["storageId"]) .index("by_objectKey", ["objectKey"]), + // Derived: which agents each strategy uses, kept current by ops.applyBatch + // so the folder tree can summarise a folder without reading its elements. + strategyAgentSummaries: defineTable({ + strategyId: v.id("strategies"), + agentTypes: v.array(v.string()), + updatedAt: v.number(), + }).index("by_strategyId", ["strategyId"]), operationEvents: defineTable({ strategyId: v.id("strategies"), pageId: v.optional(v.id("pages")), diff --git a/convex/strategies.ts b/convex/strategies.ts index f1299e6d..6bb06970 100644 --- a/convex/strategies.ts +++ b/convex/strategies.ts @@ -1,5 +1,6 @@ import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; +import { deleteStrategyAgentSummary } from "./lib/strategyAgentSummary"; import type { Doc, Id } from "./_generated/dataModel"; import type { MutationCtx, QueryCtx } from "./_generated/server"; import { @@ -671,6 +672,7 @@ const deleteStrategy = mutation({ await ctx.scheduler.runAfter(0, markDeletedStrategyImageAssetsRef, { strategyId: strategy._id, }); + await deleteStrategyAgentSummary(ctx, strategy._id); await ctx.db.delete(strategy._id); return { ok: true } as const; }, diff --git a/lib/collab/cloud_library_models.dart b/lib/collab/cloud_library_models.dart index 57dbe995..1b329db3 100644 --- a/lib/collab/cloud_library_models.dart +++ b/lib/collab/cloud_library_models.dart @@ -1,7 +1,18 @@ +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/maps.dart'; import 'package:icarus/domain/folder.dart'; import 'package:icarus/strategy/strategy_models.dart'; -typedef CloudFolderEntry = ({Folder folder, String role}); +/// A cloud folder plus what the server says it holds across its subtree: +/// strategy count, the two most-used maps, and the agents in play. The +/// client never loads a folder's strategies to draw its card. +typedef CloudFolderEntry = ({ + Folder folder, + String role, + int strategyCount, + List mapPeeks, + List agentTypes, +}); typedef CloudStrategyEntry = ({ StrategyData strategy, diff --git a/lib/collab/convex_strategy_repository.dart b/lib/collab/convex_strategy_repository.dart index d0da913a..85eb46ac 100644 --- a/lib/collab/convex_strategy_repository.dart +++ b/lib/collab/convex_strategy_repository.dart @@ -6,6 +6,7 @@ import 'package:icarus/collab/convex_client.dart'; import 'package:icarus/collab/generated/generated.dart'; import 'package:icarus/collab/transport/convex_transport.dart'; import 'package:icarus/collab/transport/convex_transport_adapter.dart'; +import 'package:icarus/const/agents.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/domain/folder.dart'; @@ -515,9 +516,34 @@ CloudFolderEntry _folderEntry(FoldersListTreeResultItem folder) { customColor: folderCustomColorFromCloud(folder.customColorValue?.toInt()), ), role: folder.role.wireName, + strategyCount: folder.strategyCount.toInt(), + mapPeeks: [ + for (final wireName in folder.mapPeeks) + if (_mapValueOrNull(wireName) case final map?) map, + ], + agentTypes: [ + for (final name in folder.agentTypes) + if (_agentTypeOrNull(name) case final type?) type, + ], ); } +/// Unknown wire names are skipped: a peek at a map or agent this build does +/// not know yet must not break the folder list. +MapValue? _mapValueOrNull(String wireName) { + for (final entry in Maps.mapNames.entries) { + if (entry.value == wireName) return entry.key; + } + return null; +} + +AgentType? _agentTypeOrNull(String name) { + for (final type in AgentType.values) { + if (type.name == name) return type; + } + return null; +} + CloudStrategyEntry _strategyEntry( StrategiesListForFolderResultItem strategy, ) { diff --git a/lib/collab/generated/convex_models.dart b/lib/collab/generated/convex_models.dart index eec835b1..1baac964 100644 --- a/lib/collab/generated/convex_models.dart +++ b/lib/collab/generated/convex_models.dart @@ -649,6 +649,7 @@ final class FoldersDeleteResult { final class FoldersListTreeResultItem { const FoldersListTreeResultItem({ + required this.agentTypes, required this.color, required this.createdAt, required this.customColorValue, @@ -656,12 +657,15 @@ final class FoldersListTreeResultItem { required this.iconFontFamily, required this.iconFontPackage, required this.iconId, + required this.mapPeeks, required this.name, required this.parentFolderPublicId, required this.publicId, required this.role, + required this.strategyCount, required this.updatedAt, }); + final List agentTypes; final String? color; final double createdAt; final double? customColorValue; @@ -669,15 +673,18 @@ final class FoldersListTreeResultItem { final String? iconFontFamily; final String? iconFontPackage; final double? iconId; + final List mapPeeks; final String name; final String? parentFolderPublicId; final String publicId; final FoldersListTreeResultItemRole role; + final double strategyCount; final double updatedAt; factory FoldersListTreeResultItem.decode(ConvexValue value, String path) { final object = _decodeObject(value, path); _checkObjectFields(object, path, const { + 'agentTypes', 'color', 'createdAt', 'customColorValue', @@ -685,13 +692,27 @@ final class FoldersListTreeResultItem { 'iconFontFamily', 'iconFontPackage', 'iconId', + 'mapPeeks', 'name', 'parentFolderPublicId', 'publicId', 'role', + 'strategyCount', 'updatedAt', }); return FoldersListTreeResultItem( + agentTypes: + _decodeArray( + object.value['agentTypes'] ?? _missing(path, 'agentTypes'), + '$path.agentTypes', + ).value.indexed + .map( + (entry) => _decodeString( + entry.$2, + _indexPath('$path.agentTypes', entry.$1), + ), + ) + .toList(growable: false), color: (object.value['color'] ?? _missing(path, 'color')) is ConvexNull ? null : _decodeString( @@ -744,6 +765,18 @@ final class FoldersListTreeResultItem { object.value['iconId'] ?? _missing(path, 'iconId'), '$path.iconId', ), + mapPeeks: + _decodeArray( + object.value['mapPeeks'] ?? _missing(path, 'mapPeeks'), + '$path.mapPeeks', + ).value.indexed + .map( + (entry) => _decodeString( + entry.$2, + _indexPath('$path.mapPeeks', entry.$1), + ), + ) + .toList(growable: false), name: _decodeString( object.value['name'] ?? _missing(path, 'name'), '$path.name', @@ -769,6 +802,10 @@ final class FoldersListTreeResultItem { ), '$path.role', ), + strategyCount: _decodeNumber( + object.value['strategyCount'] ?? _missing(path, 'strategyCount'), + '$path.strategyCount', + ), updatedAt: _decodeNumber( object.value['updatedAt'] ?? _missing(path, 'updatedAt'), '$path.updatedAt', @@ -778,6 +815,11 @@ final class FoldersListTreeResultItem { ConvexObject encode(String path) { return ConvexObject({ + 'agentTypes': ConvexArray( + agentTypes.indexed + .map((entry) => ConvexString(entry.$2)) + .toList(growable: false), + ), 'color': color == null ? const ConvexNull() : ConvexString(color!), 'createdAt': _encodeNumber(createdAt, '$path.createdAt'), 'customColorValue': customColorValue == null @@ -795,12 +837,18 @@ final class FoldersListTreeResultItem { 'iconId': iconId == null ? const ConvexNull() : _encodeNumber(iconId!, '$path.iconId'), + 'mapPeeks': ConvexArray( + mapPeeks.indexed + .map((entry) => ConvexString(entry.$2)) + .toList(growable: false), + ), 'name': ConvexString(name), 'parentFolderPublicId': parentFolderPublicId == null ? const ConvexNull() : ConvexString(parentFolderPublicId!), 'publicId': ConvexString(publicId), 'role': ConvexString(role.wireName), + 'strategyCount': _encodeNumber(strategyCount, '$path.strategyCount'), 'updatedAt': _encodeNumber(updatedAt, '$path.updatedAt'), }); } diff --git a/lib/widgets/folder_card.dart b/lib/widgets/folder_card.dart index b4431c03..20a0690c 100644 --- a/lib/widgets/folder_card.dart +++ b/lib/widgets/folder_card.dart @@ -52,14 +52,18 @@ class FolderCardViewData { mapPeeks = _collectMapPeeks(strategies), agentTypes = _collectAgents(strategies); - /// A folder whose contents are not loaded on this device, e.g. a cloud - /// folder: only its subfolder count is known. + /// A folder summarised elsewhere, e.g. by the cloud: counts, map peeks, and + /// agents arrive ready-made instead of being derived from strategies here. FolderCardViewData.summary({ required this.folder, required this.folderCount, - }) : strategyCount = null, - mapPeeks = const [], - agentTypes = const []; + this.strategyCount, + List maps = const [], + this.agentTypes = const [], + }) : mapPeeks = [ + for (final map in maps.take(2)) + 'assets/maps/thumbnails/${Maps.mapNames[map]}_thumbnail.webp', + ]; final Folder folder; diff --git a/lib/widgets/folder_content.dart b/lib/widgets/folder_content.dart index 3ad4571e..40141be0 100644 --- a/lib/widgets/folder_content.dart +++ b/lib/widgets/folder_content.dart @@ -351,6 +351,9 @@ class FolderContent extends ConsumerWidget { folderCount: tree .where((item) => item.folder.parentID == entry.folder.id) .length, + strategyCount: entry.strategyCount, + maps: entry.mapPeeks, + agentTypes: entry.agentTypes, ), ); } diff --git a/test/convex_generated_client_test.dart b/test/convex_generated_client_test.dart index 882c6f20..1efa2b83 100644 --- a/test/convex_generated_client_test.dart +++ b/test/convex_generated_client_test.dart @@ -230,6 +230,9 @@ ConvexArray _folderTreeValue({ 'publicId': const ConvexString('folder-1'), 'role': ConvexString(role), 'updatedAt': const ConvexInteger(2), + 'strategyCount': const ConvexInteger(0), + 'mapPeeks': ConvexArray(const []), + 'agentTypes': ConvexArray(const []), }), ]); diff --git a/test/providers/remote_library_provider_test.dart b/test/providers/remote_library_provider_test.dart index 79e8d26f..407b9e70 100644 --- a/test/providers/remote_library_provider_test.dart +++ b/test/providers/remote_library_provider_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/agents.dart'; import 'package:icarus/collab/cloud_library_models.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/collab/generated/generated.dart'; @@ -34,6 +35,9 @@ void main() { 'createdAt': 1700000000000, 'updatedAt': 1700000001000, 'role': 'owner', + 'strategyCount': 2, + 'mapPeeks': ['haven', 'ascent'], + 'agentTypes': ['sova', 'jett', 'not-an-agent-yet'], }, ]), ); @@ -46,6 +50,10 @@ void main() { expect(folders.single.folder.color, FolderColor.blue); expect(folders.single.folder.customColor?.toARGB32(), 0xff123456); expect(folders.single.role, 'owner'); + expect(folders.single.strategyCount, 2); + expect(folders.single.mapPeeks, [MapValue.haven, MapValue.ascent]); + // Unknown agent names from a newer server are skipped, not fatal. + expect(folders.single.agentTypes, [AgentType.sova, AgentType.jett]); }); test('repository maps typed strategy rows into Icarus strategies', () async { @@ -126,6 +134,9 @@ void main() { dateCreated: DateTime(2026), ), role: 'owner', + strategyCount: 0, + mapPeeks: const [], + agentTypes: const [], ), ( folder: Folder( @@ -134,6 +145,9 @@ void main() { dateCreated: DateTime(2026), ), role: 'editor', + strategyCount: 0, + mapPeeks: const [], + agentTypes: const [], ), ]); for (var i = 0; From 2c3f86cc3763432fac805e9e3c2e4a3cad3b9911 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:44:19 -0400 Subject: [PATCH 16/27] Space the library tabs so their backgrounds never touch Co-Authored-By: Claude Fable 5.1 --- lib/widgets/library_title_strip.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/widgets/library_title_strip.dart b/lib/widgets/library_title_strip.dart index eba8165d..2b0ee1ba 100644 --- a/lib/widgets/library_title_strip.dart +++ b/lib/widgets/library_title_strip.dart @@ -16,6 +16,8 @@ import 'package:icarus/widgets/window_chrome.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; const double _controlHeight = 28; +// Tabs sit apart so a selected and a hovered background never touch. +const double _tabGap = 4; // Action menus hug their labels; the account menu keeps room for email text. const double _sortMenuWidth = 168; const double _newMenuWidth = 140; @@ -87,6 +89,7 @@ class _LibraryTitleStripState extends ConsumerState { selected: tab == LibraryTab.library, onTap: navigation.showLibrary, ), + const SizedBox(width: _tabGap), _TabButton( key: const ValueKey('library-tab-shared'), icon: LucideIcons.users, @@ -100,6 +103,7 @@ class _LibraryTitleStripState extends ConsumerState { } }, ), + const SizedBox(width: _tabGap), _TabButton( key: const ValueKey('library-tab-community'), icon: LucideIcons.globe, From b3bc45b6f81898c1746bb6bf032771e52dfa291f Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:45:03 -0400 Subject: [PATCH 17/27] Tighten the library tabs' inner padding and icon gap Co-Authored-By: Claude Fable 5.1 --- lib/widgets/library_title_strip.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/widgets/library_title_strip.dart b/lib/widgets/library_title_strip.dart index 2b0ee1ba..050d16ac 100644 --- a/lib/widgets/library_title_strip.dart +++ b/lib/widgets/library_title_strip.dart @@ -417,7 +417,8 @@ class _TabButton extends StatelessWidget { opacity: dimmed ? 0.45 : 1, child: ShadButton.ghost( height: _controlHeight, - padding: const EdgeInsets.symmetric(horizontal: 10), + padding: const EdgeInsets.symmetric(horizontal: 8), + gap: 6, backgroundColor: selected ? theme.secondary : null, foregroundColor: foreground, hoverForegroundColor: theme.foreground, From 2767ac8103f31d763b21e81954c6bc34e2c5a267 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:57:11 -0400 Subject: [PATCH 18/27] Drop the seam line under the window strip Co-Authored-By: Claude Fable 5.1 --- lib/widgets/window_chrome.dart | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/widgets/window_chrome.dart b/lib/widgets/window_chrome.dart index 7ba3f4bd..1a414261 100644 --- a/lib/widgets/window_chrome.dart +++ b/lib/widgets/window_chrome.dart @@ -191,12 +191,9 @@ class AppWindowStrip extends StatelessWidget { return Container( key: const ValueKey('app-window-strip'), height: kWindowStripHeight, - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.card, - border: Border( - bottom: BorderSide(color: Settings.tacticalVioletTheme.border), - ), - ), + // No seam under the strip: the card tone alone separates it from the + // canvas, and the floating panels below keep their own 8px of air. + color: Settings.tacticalVioletTheme.card, child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ From c6eab17568d697122d50856b67a23b6ba3681966 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:05:20 -0400 Subject: [PATCH 19/27] Run the editor canvas up to the strip so no bare band shows under it The 8px padding under the strip exposed the scaffold background between the strip and the slightly lighter canvas, which read as a dark line. The canvas now meets the strip directly and each floating panel carries its own 8px of air instead. Co-Authored-By: Claude Fable 5.1 --- lib/sidebar.dart | 6 ++- lib/strategy_view.dart | 84 +++++++++++++++++++++--------------------- 2 files changed, 46 insertions(+), 44 deletions(-) diff --git a/lib/sidebar.dart b/lib/sidebar.dart index 950dad1b..d20ff37a 100644 --- a/lib/sidebar.dart +++ b/lib/sidebar.dart @@ -43,7 +43,11 @@ class _SideBarUIState extends ConsumerState { const AbiilityBar(), Padding( padding: const EdgeInsets.only( - left: 0, right: Settings.sideBarPanelPaddingRight, bottom: 8), + left: 0, + top: 8, + right: Settings.sideBarPanelPaddingRight, + bottom: 8, + ), child: ClipRRect( borderRadius: _panelBorderRadius, child: Container( diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart index dc111eaa..41f47e09 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -256,54 +256,52 @@ class _StrategyViewState extends ConsumerState ], ), ), - // Breathing room under the strip; every floating panel starts here. + // The canvas runs right up to the strip; each floating panel keeps + // its own 8px of air so no bare band shows between the two. Expanded( - child: Padding( - padding: const EdgeInsets.only(top: 8), - child: Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill( - child: StrategyEditBoundary(child: DeleteCapture()), - ), - Align( - alignment: Alignment.centerLeft, - child: RepaintBoundary(child: InteractiveMap()), - ), - Align( - alignment: Alignment.topLeft, - child: Padding( - padding: EdgeInsets.fromLTRB(8, 0, 8, 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - StrategyEditBoundary( - disabledOpacity: 0.55, - child: MapSelector(), - ), - SizedBox(height: 8), - EditorToolbar(), - ], - ), + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: StrategyEditBoundary(child: DeleteCapture()), + ), + Align( + alignment: Alignment.centerLeft, + child: RepaintBoundary(child: InteractiveMap()), + ), + Align( + alignment: Alignment.topLeft, + child: Padding( + padding: EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + StrategyEditBoundary( + disabledOpacity: 0.55, + child: MapSelector(), + ), + SizedBox(height: 8), + EditorToolbar(), + ], ), ), - Align( - alignment: Alignment.bottomLeft, - child: Padding( - padding: EdgeInsets.all(8.0), - child: PagesBar(), - ), + ), + Align( + alignment: Alignment.bottomLeft, + child: Padding( + padding: EdgeInsets.all(8.0), + child: PagesBar(), ), - Align( - alignment: Alignment.centerRight, - child: StrategyEditBoundary( - disabledOpacity: 0.55, - child: SideBarUI(), - ), + ), + Align( + alignment: Alignment.centerRight, + child: StrategyEditBoundary( + disabledOpacity: 0.55, + child: SideBarUI(), ), - ], - ), + ), + ], ), ), ], From e3706e7689614d78ca39986ebf89e658b16181b9 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:50:36 -0400 Subject: [PATCH 20/27] Keep the strip seam and hang the delete area off it like the map card Co-Authored-By: Claude Fable 5.1 --- lib/widgets/delete_area.dart | 4 +++- lib/widgets/window_chrome.dart | 11 ++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/widgets/delete_area.dart b/lib/widgets/delete_area.dart index 55c8066b..e121611a 100644 --- a/lib/widgets/delete_area.dart +++ b/lib/widgets/delete_area.dart @@ -226,7 +226,9 @@ class _DeleteAreaState extends ConsumerState : const Duration(milliseconds: 140); return Padding( - padding: const EdgeInsets.all(16), + // Same 8px off the strip as the map card on the left, so both hang + // from the same line. + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: CompositedTransformTarget( link: _layerLink, child: OverlayPortal( diff --git a/lib/widgets/window_chrome.dart b/lib/widgets/window_chrome.dart index 1a414261..bd5b8cd1 100644 --- a/lib/widgets/window_chrome.dart +++ b/lib/widgets/window_chrome.dart @@ -191,9 +191,14 @@ class AppWindowStrip extends StatelessWidget { return Container( key: const ValueKey('app-window-strip'), height: kWindowStripHeight, - // No seam under the strip: the card tone alone separates it from the - // canvas, and the floating panels below keep their own 8px of air. - color: Settings.tacticalVioletTheme.card, + // The 1px seam is the edge of the window frame: everything under it + // is the bench, and the floating panels on it carry their own air. + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.card, + border: Border( + bottom: BorderSide(color: Settings.tacticalVioletTheme.border), + ), + ), child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ From 4fb9b01052584d0b671cf55eab9e667420a1edc3 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:39:48 -0400 Subject: [PATCH 21/27] Show the Icarus wordmark in the strip on every desktop platform Co-Authored-By: Claude Fable 5.1 --- lib/widgets/library_title_strip.dart | 2 +- lib/widgets/window_chrome.dart | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/widgets/library_title_strip.dart b/lib/widgets/library_title_strip.dart index 050d16ac..075ee578 100644 --- a/lib/widgets/library_title_strip.dart +++ b/lib/widgets/library_title_strip.dart @@ -79,7 +79,7 @@ class _LibraryTitleStripState extends ConsumerState { return AppWindowStrip( child: Row( children: [ - const WindowsIcarusWordmark(), + const IcarusWordmark(), const SizedBox(width: 6), _TabButton( key: const ValueKey('library-tab-library'), diff --git a/lib/widgets/window_chrome.dart b/lib/widgets/window_chrome.dart index bd5b8cd1..82e43ef7 100644 --- a/lib/widgets/window_chrome.dart +++ b/lib/widgets/window_chrome.dart @@ -40,17 +40,18 @@ class WindowDragArea extends StatelessWidget { } } -/// The compact Icarus lockup at the start of the Windows library strip. -class WindowsIcarusWordmark extends StatelessWidget { - const WindowsIcarusWordmark({super.key}); +/// The compact Icarus lockup at the start of the library strip. On macOS it +/// follows the traffic-light inset; on Windows and Linux it leads the strip. +class IcarusWordmark extends StatelessWidget { + const IcarusWordmark({super.key}); @override Widget build(BuildContext context) { - if (!_isWindows) { + if (kIsWeb) { return const SizedBox.shrink(); } return Padding( - padding: const EdgeInsets.only(left: 10, right: 4), + padding: const EdgeInsets.only(left: 10, right: 6), child: SvgPicture.asset( 'assets/brand/icarus-wordmark.svg', height: 14, From 546aa3932e1cb921d3d20151051b8486d5e55f1a Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:51:32 -0400 Subject: [PATCH 22/27] Add an inset shadow decoration and the raised surface recipe Flutter has no inner shadow, so InsetShadowDecoration paints them the way Chromium does: clip to the box, fill the plane minus the offset-and-shrunk box, blur the ring. It also takes a gradient fill, outer drop shadows, and a border, and it tweens from a plain rounded BoxDecoration so animated containers can fade a selection in. Settings.raised(color, radius) builds the lit surface every selected or primary state now shares: a fill lighter at the top, a 1px light inside the top edge, a 1px shade inside the bottom, a 1px shadow beneath, bare sides. Two lightness numbers set the lift for every base color. DESIGN.md records the rule under Tonal First. Pixel tests cover the edges, the gradient, the drop shadow, and the tween. Co-Authored-By: Claude Fable 5.1 --- DESIGN.md | 2 +- lib/const/settings.dart | 82 ++++++- lib/widgets/inset_shadow_decoration.dart | 232 ++++++++++++++++++ .../widgets/inset_shadow_decoration_test.dart | 155 ++++++++++++ 4 files changed, 463 insertions(+), 8 deletions(-) create mode 100644 lib/widgets/inset_shadow_decoration.dart create mode 100644 test/widgets/inset_shadow_decoration_test.dart diff --git a/DESIGN.md b/DESIGN.md index 53d32c83..4d2b7423 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -30,7 +30,7 @@ The palette, theme, and sizing constants live in `lib/const/settings.dart`, with **The Tactical Semantics** Ally green, enemy red, defender blue, favorite amber, and the map ember hues carry game meaning. Never reuse them for unrelated UI emphasis. -**The Tonal First** Depth comes from surface steps (background, panel, raised) and 1px zinc borders. A shadow is only allowed where it explains stacking: drag previews, floating menus, card foreground details (`0 4px 12px rgba(0,0,0,0.54)` / `0 8px 24px rgba(0,0,0,0.28)`). +**The Tonal First** Depth comes from surface steps (background, panel, raised) and 1px zinc borders. A shadow is only allowed where it explains stacking: drag previews, floating menus, card foreground details (`0 4px 12px rgba(0,0,0,0.54)` / `0 8px 24px rgba(0,0,0,0.28)`). A selected or primary state is never a flat fill: it is a raised surface, lit from above. The fill runs lighter at the top, a 1px light sits inside the top edge, a 1px shade inside the bottom, and a 1px shadow drops beneath; the sides stay bare. `Settings.raised(color, radius)` builds it for any base color (`raisedPrimary` and `raisedSurface` are the violet and zinc shortcuts), painted by `InsetShadowDecoration` (`lib/widgets/inset_shadow_decoration.dart`), which also tweens in animated containers. Primary buttons get it from the Shad theme. Hover stays flat. **Every control earns its position.** If you can't say why a control sits where it sits, it isn't done. Never fill spare space with a feature. diff --git a/lib/const/settings.dart b/lib/const/settings.dart index 9bb5f754..f6add7ac 100644 --- a/lib/const/settings.dart +++ b/lib/const/settings.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:icarus/const/color_option.dart'; +import 'package:icarus/widgets/inset_shadow_decoration.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:toastification/toastification.dart'; @@ -236,6 +237,79 @@ class Settings { static const Color settingsDiscordAccent = Color(0xff5865f2); // brand blurple static const Color settingsMapAccent = Color(0xffb27c40); // map layers + // Resting glyph color for toolbar controls: a step under foreground so the + // strip of icons stays quiet, but above mutedForeground, which vanishes at + // the light stroke weights. Hover still comes up to foreground. + static const Color toolbarGlyph = Color(0xffd4d4d8); // zinc-300 + + // Raised surfaces (a selected tab, a checked tool, a primary command) are + // lit from above: the fill runs lighter at the top, a bright 1px edge sits + // inside the top, a dark 1px edge inside the bottom, and a 1px shadow drops + // beneath. The sides stay bare. Everything paints inside or 1px under the + // box, so the footprint never changes. Hover stays flat. + static const Color raisedTopLight = Color(0x24ffffff); // white 14% + static const Color raisedBottomShade = Color(0x4d000000); // black 30% + static const List raisedRim = [ + InsetShadow(color: raisedTopLight, offset: Offset(0, 1)), + InsetShadow(color: raisedBottomShade, offset: Offset(0, -1)), + ]; + static const BoxShadow raisedDropShadow = BoxShadow( + color: Color(0x73000000), // black 45% + offset: Offset(0, 1), + ); + // How far the fill's top and bottom move from the base color, in HSL + // lightness. These two numbers set the lift for every raised surface. + static const double raisedTopLift = 0.06; + static const double raisedBottomDrop = 0.03; + + /// The lit fill for any base color: lighter at the top, darker at the + /// bottom, so one recipe serves violet, zinc, red, and the rest. + static LinearGradient raisedGradient(Color base) { + final hsl = HSLColor.fromColor(base); + return LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + hsl + .withLightness((hsl.lightness + raisedTopLift).clamp(0, 1)) + .toColor(), + hsl + .withLightness((hsl.lightness - raisedBottomDrop).clamp(0, 1)) + .toColor(), + ], + ); + } + + /// A raised surface of [base] color at [radius]. Use this wherever a + /// selected or primary state would otherwise be a flat fill. + static InsetShadowDecoration raised(Color base, double radius) => + InsetShadowDecoration( + gradient: raisedGradient(base), + borderRadius: BorderRadius.circular(radius), + boxShadows: const [raisedDropShadow], + shadows: raisedRim, + ); + + /// The raised neutral surface: a selected tab or chip. + static InsetShadowDecoration raisedSurface(double radius) => + raised(tacticalVioletTheme.secondary, radius); + + /// The raised command surface: a checked tool, the active segment, the + /// active page, anything that would otherwise be a flat `primary` fill. + static InsetShadowDecoration raisedPrimary(double radius) => + raised(tacticalVioletTheme.primary, radius); + + /// The primary fill alone, for the Shad theme and animated fills. + static final LinearGradient raisedPrimaryFill = + raisedGradient(tacticalVioletTheme.primary); + + // The shadow a floating menu earns (DESIGN.md: 0 8px 24px rgba(0,0,0,0.28)). + static const BoxShadow floatingMenuShadow = BoxShadow( + color: Color(0x47000000), + blurRadius: 24, + offset: Offset(0, 8), + ); + static const cardForegroundBackdrop = BoxShadow( color: Colors.black54, // High opacity because the background is dark blurRadius: 12, @@ -260,13 +334,7 @@ class Settings { return Container( margin: const EdgeInsets.all(16), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - ), - ), + decoration: Settings.raised(backgroundColor, 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/widgets/inset_shadow_decoration.dart b/lib/widgets/inset_shadow_decoration.dart new file mode 100644 index 00000000..6707228d --- /dev/null +++ b/lib/widgets/inset_shadow_decoration.dart @@ -0,0 +1,232 @@ +import 'dart:ui' as ui; + +import 'package:flutter/painting.dart'; + +/// A shadow cast inward from the edge of a box, like CSS `box-shadow: inset`. +/// +/// With [blurRadius] zero this is a crisp inner edge: a 1px [spreadRadius] +/// draws a rim all the way round, a 1px [offset] draws a lit or shaded edge on +/// one side. Flutter's [BoxShadow] cannot do either, so this pairs with +/// [InsetShadowDecoration]. +class InsetShadow { + const InsetShadow({ + required this.color, + this.offset = Offset.zero, + this.blurRadius = 0, + this.spreadRadius = 0, + }); + + final Color color; + final Offset offset; + final double blurRadius; + final double spreadRadius; + + InsetShadow _faded() => InsetShadow( + color: color.withValues(alpha: 0), + offset: offset, + blurRadius: blurRadius, + spreadRadius: spreadRadius, + ); + + static InsetShadow lerp(InsetShadow a, InsetShadow b, double t) { + return InsetShadow( + color: Color.lerp(a.color, b.color, t)!, + offset: Offset.lerp(a.offset, b.offset, t)!, + blurRadius: ui.lerpDouble(a.blurRadius, b.blurRadius, t)!, + spreadRadius: ui.lerpDouble(a.spreadRadius, b.spreadRadius, t)!, + ); + } + + /// Lerps two lists, fading in or out whatever one side lacks. + static List lerpList( + List a, + List b, + double t, + ) { + final length = a.length > b.length ? a.length : b.length; + return [ + for (var i = 0; i < length; i++) + lerp( + i < a.length ? a[i] : b[i]._faded(), + i < b.length ? b[i] : a[i]._faded(), + t, + ), + ]; + } +} + +/// A rounded box with a fill (flat or [gradient]), optional border, outer +/// [boxShadows], and any number of [InsetShadow]s painted inside its edge. +/// +/// Each shadow uses the technique Chromium uses for inset shadows: clip to +/// the box, fill the plane minus a copy of the box that has been moved by the +/// shadow's offset and shrunk by its spread, then blur that ring. The border +/// paints last so it stays crisp over the shadows. +class InsetShadowDecoration extends Decoration { + const InsetShadowDecoration({ + this.color, + this.gradient, + this.borderRadius = BorderRadius.zero, + this.border, + this.boxShadows = const [], + this.shadows = const [], + }); + + final Color? color; + + /// Painted over [color] when set, so a gradient can sit on a base fill. + final Gradient? gradient; + final BorderRadius borderRadius; + final BoxBorder? border; + + /// Ordinary drop shadows, painted outside the box before the fill. + final List boxShadows; + final List shadows; + + @override + EdgeInsetsGeometry get padding => border?.dimensions ?? EdgeInsets.zero; + + @override + Path getClipPath(Rect rect, TextDirection textDirection) { + return Path()..addRRect(borderRadius.toRRect(rect)); + } + + @override + bool hitTest(Size size, Offset position, {TextDirection? textDirection}) { + return borderRadius.toRRect(Offset.zero & size).contains(position); + } + + @override + BoxPainter createBoxPainter([VoidCallback? onChanged]) { + return _InsetShadowPainter(this, onChanged); + } + + // Animated containers can tween to and from this decoration, including + // from a plain rounded [BoxDecoration], so a selected state can fade in. + @override + Decoration? lerpFrom(Decoration? a, double t) { + final from = _coerce(a); + return from == null ? null : _lerp(from, this, t); + } + + @override + Decoration? lerpTo(Decoration? b, double t) { + final to = _coerce(b); + return to == null ? null : _lerp(this, to, t); + } + + static InsetShadowDecoration? _coerce(Decoration? other) { + if (other == null) return const InsetShadowDecoration(); + if (other is InsetShadowDecoration) return other; + if (other is BoxDecoration && + other.shape == BoxShape.rectangle && + other.image == null && + other.backgroundBlendMode == null && + (other.borderRadius == null || other.borderRadius is BorderRadius) && + (other.border == null || other.border is Border)) { + return InsetShadowDecoration( + color: other.color, + gradient: other.gradient, + borderRadius: + (other.borderRadius as BorderRadius?) ?? BorderRadius.zero, + border: other.border, + boxShadows: other.boxShadow ?? const [], + ); + } + return null; + } + + static InsetShadowDecoration _lerp( + InsetShadowDecoration a, + InsetShadowDecoration b, + double t, + ) { + return InsetShadowDecoration( + color: Color.lerp(a.color, b.color, t), + gradient: Gradient.lerp(a.gradient, b.gradient, t), + borderRadius: BorderRadius.lerp(a.borderRadius, b.borderRadius, t)!, + border: BoxBorder.lerp(a.border, b.border, t), + boxShadows: BoxShadow.lerpList(a.boxShadows, b.boxShadows, t) ?? const [], + shadows: InsetShadow.lerpList(a.shadows, b.shadows, t), + ); + } +} + +class _InsetShadowPainter extends BoxPainter { + _InsetShadowPainter(this.decoration, super.onChanged); + + final InsetShadowDecoration decoration; + + @override + void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { + final size = configuration.size; + if (size == null) { + return; + } + final rect = offset & size; + final rrect = decoration.borderRadius.toRRect(rect); + + for (final shadow in decoration.boxShadows) { + canvas.drawRRect( + rrect.shift(shadow.offset).inflate(shadow.spreadRadius), + shadow.toPaint(), + ); + } + + final color = decoration.color; + if (color != null) { + canvas.drawRRect(rrect, Paint()..color = color); + } + final gradient = decoration.gradient; + if (gradient != null) { + canvas.drawRRect( + rrect, + Paint() + ..shader = gradient.createShader( + rect, + textDirection: configuration.textDirection, + ), + ); + } + + for (final shadow in decoration.shadows) { + _paintShadow(canvas, rect, rrect, shadow); + } + + decoration.border?.paint( + canvas, + rect, + textDirection: configuration.textDirection, + borderRadius: decoration.borderRadius, + ); + } + + void _paintShadow(Canvas canvas, Rect rect, RRect rrect, InsetShadow shadow) { + // The hole is the box itself, moved by the offset and shrunk by the + // spread; whatever it no longer covers inside the clip is the shadow. + final hole = rrect.shift(shadow.offset).deflate(shadow.spreadRadius); + // Extend the filled area past the clip so the blur never fades at the + // box edge, only at the hole edge. + final reach = shadow.blurRadius + shadow.spreadRadius; + final outer = rect.inflate(reach + shadow.offset.distance + 1); + + final ring = Path() + ..fillType = PathFillType.evenOdd + ..addRect(outer) + ..addRRect(hole); + + final paint = Paint()..color = shadow.color; + if (shadow.blurRadius > 0) { + paint.maskFilter = ui.MaskFilter.blur( + ui.BlurStyle.normal, + Shadow.convertRadiusToSigma(shadow.blurRadius), + ); + } + + canvas + ..save() + ..clipRRect(rrect) + ..drawPath(ring, paint) + ..restore(); + } +} diff --git a/test/widgets/inset_shadow_decoration_test.dart b/test/widgets/inset_shadow_decoration_test.dart new file mode 100644 index 00000000..b606a33a --- /dev/null +++ b/test/widgets/inset_shadow_decoration_test.dart @@ -0,0 +1,155 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/widgets/inset_shadow_decoration.dart'; + +const _size = Size(120, 40); +const _fill = Color(0xff27272a); + +Future<_Pixels> _render(WidgetTester tester, Decoration decoration) async { + final key = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: RepaintBoundary( + key: key, + child: SizedBox( + width: _size.width, + height: _size.height + 1, + child: Align( + alignment: Alignment.topCenter, + child: Container( + width: _size.width, + height: _size.height, + decoration: decoration, + ), + ), + ), + ), + ), + ), + ); + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + // Engine futures only resolve inside runAsync; the fake-async test zone + // never completes them. + late _Pixels pixels; + await tester.runAsync(() async { + final image = await boundary.toImage(); + final dump = Platform.environment['INSET_SHADOW_DUMP']; + if (dump != null) { + final png = await image.toByteData(format: ui.ImageByteFormat.png); + File(dump).writeAsBytesSync(png!.buffer.asUint8List()); + } + pixels = _Pixels(image.width, (await image.toByteData())!); + }); + return pixels; +} + +class _Pixels { + const _Pixels(this.width, this.bytes); + + final int width; + final ByteData bytes; + + Color at(int x, int y) { + final i = (y * width + x) * 4; + return Color.fromARGB( + bytes.getUint8(i + 3), + bytes.getUint8(i), + bytes.getUint8(i + 1), + bytes.getUint8(i + 2), + ); + } +} + +void main() { + testWidgets('raised rim lightens the edges and the top edge most', + (tester) async { + final image = await _render( + tester, + InsetShadowDecoration( + color: _fill, + borderRadius: BorderRadius.circular(6), + shadows: Settings.raisedRim, + ), + ); + final centre = image.at(60, 20); + final top = image.at(60, 0); + final bottom = image.at(60, 39); + final left = image.at(0, 20); + final inside = image.at(60, 2); + + expect(centre, _fill, reason: 'the face keeps the plain fill'); + expect(inside, _fill, reason: 'the edges are one pixel deep'); + expect(left, _fill, reason: 'the sides stay bare'); + expect(top.r, greaterThan(centre.r), reason: 'top edge is lit'); + expect(bottom.r, lessThan(centre.r), reason: 'bottom edge is shaded'); + }); + + testWidgets('gradient paints over the fill and drop shadows fall outside', + (tester) async { + final image = await _render( + tester, + InsetShadowDecoration( + gradient: Settings.raisedGradient(_fill), + borderRadius: BorderRadius.circular(6), + boxShadows: const [Settings.raisedDropShadow], + ), + ); + expect(image.at(60, 4).r, greaterThan(image.at(60, 35).r), + reason: 'the fill runs lighter at the top'); + expect(image.at(60, 40).a, greaterThan(0), + reason: 'the shadow lands one pixel under the box'); + }); + + testWidgets('a blurred inset shadow darkens toward the edge', + (tester) async { + final image = await _render( + tester, + const InsetShadowDecoration( + color: Color(0xffffffff), + shadows: [ + InsetShadow(color: Color(0xff000000), blurRadius: 8), + ], + ), + ); + final centre = image.at(60, 20); + final nearEdge = image.at(60, 1); + final midway = image.at(60, 4); + expect(centre, const Color(0xffffffff)); + expect(nearEdge.r, lessThan(midway.r)); + expect(midway.r, lessThan(centre.r)); + }); + + testWidgets('without shadows nothing paints outside the fill', + (tester) async { + final image = await _render( + tester, + const InsetShadowDecoration(color: _fill), + ); + expect(image.at(0, 0), _fill); + expect(image.at(119, 39), _fill); + }); + + test('tweens from a plain rounded BoxDecoration', () { + final from = BoxDecoration( + color: _fill, + borderRadius: BorderRadius.circular(6), + ); + final to = Settings.raisedPrimary(6); + final mid = Decoration.lerp(from, to, 0.5); + expect(mid, isA()); + final raised = mid! as InsetShadowDecoration; + expect(raised.shadows, hasLength(2)); + expect(raised.shadows.first.color.a, closeTo(0.07, 0.01), + reason: 'the top light fades in halfway'); + expect(Decoration.lerp(to, from, 0.75), isA()); + }); +} From bb61e5d8c8d1dc5ab53ff1397ef1fec4370e19bf Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:51:32 -0400 Subject: [PATCH 23/27] Quiet the editor toolbar and square up the map card Toolbar glyphs drop from the 300 to the 200 Lucide weight and rest on zinc-300 instead of full white, coming up to foreground on hover. The sync popover pins its text to the start (the editor's centered alignment leaked in), loses its bold title, and puts its actions in one right-aligned row. The map card was 262pt wide with 260pt of contents, so the slack landed on the right of the side toggle. Its width is derived from the tile, gap, and toggle now, so both sides get the same 4pt. Co-Authored-By: Claude Fable 5.1 --- lib/widgets/cloud_sync_button.dart | 114 +++++++++++---------- lib/widgets/editor_toolbar.dart | 21 ++-- lib/widgets/map_selector.dart | 14 ++- lib/widgets/map_tile.dart | 8 +- lib/widgets/strategy_save_icon_button.dart | 4 +- 5 files changed, 93 insertions(+), 68 deletions(-) diff --git a/lib/widgets/cloud_sync_button.dart b/lib/widgets/cloud_sync_button.dart index 77e651db..84129ae5 100644 --- a/lib/widgets/cloud_sync_button.dart +++ b/lib/widgets/cloud_sync_button.dart @@ -246,14 +246,14 @@ class _CloudSyncButtonState extends ConsumerState { switch (status) { case _SyncStatus.synced: return Icon( - LucideIcons.cloudCheck300, + LucideIcons.cloudCheck200, key: const ValueKey('synced'), size: size, color: color, ); case _SyncStatus.editing: return Icon( - LucideIcons.cloudUpload300, + LucideIcons.cloudUpload200, key: const ValueKey('editing'), size: size, color: color, @@ -272,14 +272,14 @@ class _CloudSyncButtonState extends ConsumerState { ); case _SyncStatus.offline: return Icon( - LucideIcons.cloudOff300, + LucideIcons.cloudOff200, key: const ValueKey('offline'), size: size, color: color, ); case _SyncStatus.attention: return Icon( - LucideIcons.cloudAlert300, + LucideIcons.cloudAlert200, key: const ValueKey('attention'), size: size, color: color, @@ -338,68 +338,78 @@ class _SyncStatusPopover extends StatelessWidget { final theme = ShadTheme.of(context); final lastSynced = saveState.lastPersistedAt; - return SizedBox( - width: 260, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _title, - style: theme.textTheme.small.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 6), - Text( - _explanation, - style: theme.textTheme.small.copyWith( - color: theme.colorScheme.mutedForeground, - height: 1.35, - ), - ), - if (resolutionError != null) ...[ - const SizedBox(height: 8), + // Text alignment is inherited from the editor, which centers, so pin it + // here; the column's own alignment does not reach inside the Texts. + return DefaultTextStyle.merge( + textAlign: TextAlign.start, + child: SizedBox( + width: 260, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - resolutionError!, + _title, style: theme.textTheme.small.copyWith( - color: theme.colorScheme.destructive, - height: 1.35, + color: theme.colorScheme.foreground, ), ), - ], - if (lastSynced != null) ...[ - const SizedBox(height: 8), + const SizedBox(height: 6), Text( - 'Last synced at ${_formatTime(lastSynced)}', + _explanation, style: theme.textTheme.small.copyWith( color: theme.colorScheme.mutedForeground, - fontSize: 11, + height: 1.35, ), ), - ], - if (status == _SyncStatus.attention && - (!hasOtherStrategyAttention || hasActiveStrategyAttention)) ...[ - const SizedBox(height: 12), - if (hasRejectedWork) ...[ - ShadButton.secondary( - size: ShadButtonSize.sm, - expands: false, - onPressed: isResolving ? null : onUseCloudVersions, - child: const Text('Use cloud'), + if (resolutionError != null) ...[ + const SizedBox(height: 8), + Text( + resolutionError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + height: 1.35, + ), ), + ], + if (lastSynced != null) ...[ const SizedBox(height: 8), + Text( + 'Last synced at ${_formatTime(lastSynced)}', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + fontSize: 11, + ), + ), ], - ShadButton( - size: ShadButtonSize.sm, - expands: false, - onPressed: isResolving ? null : onRetry, - child: Text( - hasRejectedWork ? 'Keep mine' : 'Retry sync', + if (status == _SyncStatus.attention && + (!hasOtherStrategyAttention || hasActiveStrategyAttention)) ...[ + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (hasRejectedWork) ...[ + ShadButton.secondary( + size: ShadButtonSize.sm, + expands: false, + onPressed: isResolving ? null : onUseCloudVersions, + child: const Text('Use cloud'), + ), + const SizedBox(width: 8), + ], + ShadButton( + size: ShadButtonSize.sm, + expands: false, + onPressed: isResolving ? null : onRetry, + child: Text( + hasRejectedWork ? 'Keep mine' : 'Retry sync', + ), + ), + ], ), - ), + ], ], - ], + ), ), ); } diff --git a/lib/widgets/editor_toolbar.dart b/lib/widgets/editor_toolbar.dart index b988bbb2..09c0d474 100644 --- a/lib/widgets/editor_toolbar.dart +++ b/lib/widgets/editor_toolbar.dart @@ -81,13 +81,13 @@ class _EditorToolbarState extends ConsumerState { style: style, tooltip: 'Export .ica', onPressed: _exportStrategy, - icon: const Icon(LucideIcons.upload300), + icon: const Icon(LucideIcons.upload200), ), EditorToolbarButton( style: style, tooltip: 'Export video', onPressed: _exportVideo, - icon: const Icon(LucideIcons.clapperboard300), + icon: const Icon(LucideIcons.clapperboard200), ), EditorToolbarButton( style: style, @@ -104,7 +104,7 @@ class _EditorToolbarState extends ConsumerState { ), ), ) - : const Icon(LucideIcons.camera300), + : const Icon(LucideIcons.camera200), ), const EditorToolbarDivider(), EditorToolbarButton( @@ -116,7 +116,7 @@ class _EditorToolbarState extends ConsumerState { builder: (context) => const SettingsTab(), ); }, - icon: const Icon(LucideIcons.settings300), + icon: const Icon(LucideIcons.settings200), ), ], ), @@ -274,10 +274,11 @@ class _EditorToolbarState extends ConsumerState { } } -/// One control in the editor toolbar. Glyphs are the 300 stroke weight: the -/// default 2px Lucide stroke reads heavy in white at 18px, and muted grey -/// vanishes against the card, so the weight carries the quietness instead. [icon] is any 18px glyph, so buttons can swap -/// in a spinner without changing size. +/// One control in the editor toolbar. Glyphs are the 200 stroke weight and +/// rest in [Settings.toolbarGlyph]: the default 2px Lucide stroke reads heavy +/// at 18px, and full white on top of it shouts, so the weight and a step of +/// grey share the quietness. Hover brings the glyph up to foreground. [icon] +/// is any 18px glyph, so buttons can swap in a spinner without changing size. class EditorToolbarButton extends StatelessWidget { const EditorToolbarButton({ super.key, @@ -303,7 +304,7 @@ class EditorToolbarButton extends StatelessWidget { @override Widget build(BuildContext context) { const theme = Settings.tacticalVioletTheme; - final resting = foregroundColor ?? theme.foreground; + final resting = foregroundColor ?? Settings.toolbarGlyph; return Semantics( label: semanticsLabel ?? tooltip, button: true, @@ -369,7 +370,7 @@ class _ViewOnlyChip extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(LucideIcons.eye300, size: 14, color: theme.mutedForeground), + Icon(LucideIcons.eye200, size: 14, color: theme.mutedForeground), const SizedBox(width: 6), Text( 'View only', diff --git a/lib/widgets/map_selector.dart b/lib/widgets/map_selector.dart index fe179a82..34bded59 100644 --- a/lib/widgets/map_selector.dart +++ b/lib/widgets/map_selector.dart @@ -16,12 +16,19 @@ class MapSelector extends ConsumerStatefulWidget { } class _MapSelectorState extends ConsumerState { - static const double _cardWidth = 262; static const double _cardHeight = 65; static const double _outerRadius = 12; static const double _innerGap = 4; static const double _innerRadius = _outerRadius - _innerGap; + static const double _borderWidth = 1; static const double _sideToggleWidth = 66; + // Sized from the contents so the gap on the right of the side toggle equals + // the gap on the left of the map tile. + static const double _cardWidth = 2 * _borderWidth + + 2 * _innerGap + + MapTile.width + + _innerGap + + _sideToggleWidth; final OverlayPortalController _controller = OverlayPortalController(); final _link = LayerLink(); @@ -77,7 +84,10 @@ class _MapSelectorState extends ConsumerState { decoration: BoxDecoration( color: Settings.tacticalVioletTheme.card, borderRadius: const BorderRadius.all(Radius.circular(_outerRadius)), - border: Border.all(color: Settings.tacticalVioletTheme.border), + border: Border.all( + color: Settings.tacticalVioletTheme.border, + width: _borderWidth, + ), ), width: _cardWidth, height: _cardHeight, diff --git a/lib/widgets/map_tile.dart b/lib/widgets/map_tile.dart index fc1d9e83..eaf58c5d 100644 --- a/lib/widgets/map_tile.dart +++ b/lib/widgets/map_tile.dart @@ -18,6 +18,10 @@ class MapTile extends ConsumerStatefulWidget { final bool isActive; final double borderRadius; + /// The tile's fixed footprint; the map card sizes itself from this. + static const double width = 180; + static const double height = 65; + @override ConsumerState createState() => _MapTileState(); } @@ -38,8 +42,8 @@ class _MapTileState extends ConsumerState { mouseCursor: SystemMouseCursors.click, onTap: widget.onTap, child: SizedBox( - width: 180, - height: 65, + width: MapTile.width, + height: MapTile.height, child: Stack( children: [ Positioned.fill( diff --git a/lib/widgets/strategy_save_icon_button.dart b/lib/widgets/strategy_save_icon_button.dart index 596a4556..13b2c825 100644 --- a/lib/widgets/strategy_save_icon_button.dart +++ b/lib/widgets/strategy_save_icon_button.dart @@ -116,7 +116,7 @@ class _AutoSaveButtonState extends ConsumerState { final size = widget.style.iconSize; final Widget icon = switch (_phase) { - _Phase.idle => Icon(LucideIcons.save300, key: const ValueKey('idle')), + _Phase.idle => Icon(LucideIcons.save200, key: const ValueKey('idle')), _Phase.loading => SizedBox( key: const ValueKey('loading'), width: size - 2, @@ -129,7 +129,7 @@ class _AutoSaveButtonState extends ConsumerState { ), ), _Phase.success => Icon( - LucideIcons.check300, + LucideIcons.check200, key: const ValueKey('success'), color: Settings.allyBGColor, ), From 017a2ec5f1a7ebfc70e7c65faa249076507ab30e Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:51:32 -0400 Subject: [PATCH 24/27] Raise every selected state and rebuild the menus that sat outside the system Selected and primary states are raised surfaces now instead of flat violet or zinc fills: the library tab, checked sidebar tools, the thickness and agent-filter pills, the active page row and its transition sweep, the strategy card's hover ring, primary buttons through the Shad theme, the placement banner, and toasts. The page row is rebuilt as fixed layers so switching states no longer shifts its content by the resting border's width. Active items keep their siblings' weight; the surface marks them. The strategy quick switcher becomes a proper menu: popover grey, hairline border, 12px radius, the floating-menu shadow, flat ghost rows with a thumbnail. Its bar clips both segments so hover fills stop spilling past the border. The cloud-exit dialog lays its actions out in one wrapping row with sentence-case labels. The cloud outbox banner stops pushing the library grid down and floats in the corner as a status card instead. Co-Authored-By: Claude Fable 5.1 --- lib/main.dart | 14 + lib/services/unsaved_strategy_guard.dart | 76 ++--- lib/widgets/cloud_outbox_summary_banner.dart | 68 +++-- lib/widgets/custom_segmented_tabs.dart | 17 +- lib/widgets/folder_navigator.dart | 12 +- lib/widgets/library_title_strip.dart | 32 ++- lib/widgets/line_up_placer.dart | 9 +- lib/widgets/pages_bar.dart | 62 ++-- lib/widgets/selectable_icon_button.dart | 22 +- lib/widgets/strategy_quick_switcher.dart | 286 +++++++++---------- lib/widgets/strategy_tile/strategy_tile.dart | 88 +++--- test/unsaved_strategy_guard_test.dart | 28 +- 12 files changed, 390 insertions(+), 324 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index c9490f48..18d8c555 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -566,6 +566,20 @@ class _MyAppState extends ConsumerState { ghostButtonTheme: ShadButtonTheme( foregroundColor: Settings.tacticalVioletTheme.foreground, ), + // Primary commands are raised like the selected tab: a lighter top + // of the fill, a bright 1px edge inside the top, a 1px shadow + // beneath. A rounded Border must be one color, so the theme paints + // the top light only and the gradient carries the bottom shade. + primaryButtonTheme: ShadButtonTheme( + decoration: ShadDecoration( + gradient: Settings.raisedPrimaryFill, + shadows: [Settings.raisedDropShadow], + border: const ShadBorder( + radius: BorderRadius.all(Radius.circular(6)), + top: ShadBorderSide(color: Settings.raisedTopLight, width: 1), + ), + ), + ), ), home: const MyHomePage(), routes: { diff --git a/lib/services/unsaved_strategy_guard.dart b/lib/services/unsaved_strategy_guard.dart index ea9c7ee6..5b069474 100644 --- a/lib/services/unsaved_strategy_guard.dart +++ b/lib/services/unsaved_strategy_guard.dart @@ -33,14 +33,11 @@ Future showUnsavedStrategyDialog( builder: (context) { return ShadDialog.alert( title: const Text('Save changes?'), - description: const Padding( - padding: EdgeInsets.all(8), - child: Text( - 'This strategy has unsaved changes. Do you want to save before leaving?', - ), + description: const Text( + 'This strategy has unsaved changes. Do you want to save before leaving?', ), actions: [ - ShadButton.secondary( + ShadButton.ghost( onPressed: () { Navigator.of(context).pop(UnsavedStrategyDecision.cancel); }, @@ -50,7 +47,7 @@ Future showUnsavedStrategyDialog( onPressed: () { Navigator.of(context).pop(UnsavedStrategyDecision.dontSave); }, - child: const Text("Don't Save"), + child: const Text("Don't save"), ), ShadButton( onPressed: () { @@ -77,37 +74,44 @@ Future _showCloudSyncBlockedDialog( builder: (context) { return ShadDialog.alert( title: const Text('Cloud sync pending'), - actionsAxis: Axis.vertical, - description: Padding( - padding: const EdgeInsets.all(8), - child: Text(message), - ), + description: Text(message), + // One row that wraps when the labels need more room: the quiet way + // out first, the command last. actions: [ - ShadButton.secondary( - onPressed: () { - Navigator.of(context).pop(CloudExitDecision.stay); - }, - child: const Text('Stay Here'), - ), - if (showRetryAuth) - ShadButton.secondary( - onPressed: () { - Navigator.of(context).pop(CloudExitDecision.retryAuth); - }, - child: const Text('Retry Convex Auth'), + Expanded( + child: Wrap( + alignment: WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: [ + ShadButton.ghost( + onPressed: () { + Navigator.of(context).pop(CloudExitDecision.stay); + }, + child: const Text('Stay here'), + ), + if (showRetryAuth) + ShadButton.secondary( + onPressed: () { + Navigator.of(context).pop(CloudExitDecision.retryAuth); + }, + child: const Text('Retry sign-in'), + ), + if (allowLeaveAnyway) + ShadButton.secondary( + onPressed: () { + Navigator.of(context).pop(CloudExitDecision.leaveAnyway); + }, + child: const Text('Leave anyway'), + ), + ShadButton( + onPressed: () { + Navigator.of(context).pop(CloudExitDecision.retrySync); + }, + child: const Text('Retry sync'), + ), + ], ), - if (allowLeaveAnyway) - ShadButton.secondary( - onPressed: () { - Navigator.of(context).pop(CloudExitDecision.leaveAnyway); - }, - child: const Text('Leave Anyway'), - ), - ShadButton( - onPressed: () { - Navigator.of(context).pop(CloudExitDecision.retrySync); - }, - child: const Text('Retry Sync'), ), ], ); diff --git a/lib/widgets/cloud_outbox_summary_banner.dart b/lib/widgets/cloud_outbox_summary_banner.dart index 96b0b514..c7431b55 100644 --- a/lib/widgets/cloud_outbox_summary_banner.dart +++ b/lib/widgets/cloud_outbox_summary_banner.dart @@ -78,44 +78,56 @@ class CloudOutboxSummaryBanner extends ConsumerWidget { 'waiting on this device and will resume when the ' 'connection returns.'; final theme = ShadTheme.of(context); + // A floating status card, not a banner: it sits over the library's + // corner like a floating menu and never moves the grid. The parent + // positions it. return Container( key: const ValueKey('cloud-outbox-summary'), - margin: const EdgeInsets.fromLTRB(24, 16, 24, 0), + constraints: const BoxConstraints(maxWidth: 360), padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.card, + color: Settings.tacticalVioletTheme.popover, border: Border.all(color: Settings.tacticalVioletTheme.border), borderRadius: BorderRadius.circular(12), + boxShadow: const [Settings.floatingMenuShadow], ), child: Row( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - needsAttention - ? LucideIcons.circleAlert - : connected - ? LucideIcons.cloudUpload - : LucideIcons.cloudOff, - size: 18, - color: needsAttention - ? theme.colorScheme.destructive - : theme.colorScheme.mutedForeground, + Padding( + padding: const EdgeInsets.only(top: 1), + child: Icon( + needsAttention + ? LucideIcons.circleAlert + : connected + ? LucideIcons.cloudUpload + : LucideIcons.cloudOff, + size: 16, + color: needsAttention + ? theme.colorScheme.destructive + : theme.colorScheme.mutedForeground, + ), ), const SizedBox(width: 10), - Expanded( + Flexible( child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, - style: theme.textTheme.small.copyWith( - fontWeight: FontWeight.w600, + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, ), ), - const SizedBox(height: 4), + const SizedBox(height: 2), Text( detail, - style: theme.textTheme.small.copyWith( + style: TextStyle( + fontSize: 12, + height: 1.35, color: theme.colorScheme.mutedForeground, ), ), @@ -126,16 +138,22 @@ class CloudOutboxSummaryBanner extends ConsumerWidget { runSpacing: 8, children: [ for (final strategyId in attentionIds) - ShadButton.outline( + ShadButton.secondary( size: ShadButtonSize.sm, onPressed: () => _openStrategy(context, strategyId), - child: Text(_attentionLabel( - strategyId, - strategyNames[strategyId], - opQueue - .accountOutbox.strategies[strategyId]?.reason, - failedMediaByStrategy[strategyId] ?? 0, - )), + child: Flexible( + child: Text( + _attentionLabel( + strategyId, + strategyNames[strategyId], + opQueue.accountOutbox.strategies[strategyId] + ?.reason, + failedMediaByStrategy[strategyId] ?? 0, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), ), ], ), diff --git a/lib/widgets/custom_segmented_tabs.dart b/lib/widgets/custom_segmented_tabs.dart index db0d7d67..ad506724 100644 --- a/lib/widgets/custom_segmented_tabs.dart +++ b/lib/widgets/custom_segmented_tabs.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/widgets/inset_shadow_decoration.dart'; enum SegmentedIndicatorBehavior { slidingPill, @@ -207,10 +208,7 @@ class _CustomSegmentedTabsState extends State> { width: _segmentWidths[selectedIndex], bottom: 0, child: DecoratedBox( - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.primary, - borderRadius: BorderRadius.circular(_segmentRadius), - ), + decoration: Settings.raisedPrimary(_segmentRadius), ), ), Row( @@ -296,12 +294,11 @@ class _TabButton extends StatelessWidget { horizontal: horizontalPadding, vertical: verticalPadding, ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(borderRadius), - color: showStaticFill && isSelected - ? Settings.tacticalVioletTheme.primary - : Colors.transparent, - ), + decoration: showStaticFill && isSelected + ? Settings.raisedPrimary(borderRadius) + : InsetShadowDecoration( + borderRadius: BorderRadius.circular(borderRadius), + ), child: DefaultTextStyle.merge( style: TextStyle( color: textColor, diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index 5e721e04..0fa30de7 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -320,7 +320,6 @@ class _FolderNavigatorState extends ConsumerState { onImportBackup: handleImportBackup, onExportLibrary: handleExportLibrary, ), - if (tab != LibraryTab.community) const CloudOutboxSummaryBanner(), Expanded( child: ShadContextMenuRegion( controller: _backgroundMenuController, @@ -328,8 +327,7 @@ class _FolderNavigatorState extends ConsumerState { ? const [] : [ ShadContextMenuItem( - leading: - const Icon(LucideIcons.folderPlus), + leading: const Icon(LucideIcons.folderPlus), onPressed: showCreateFolderDialog, child: const Text('Create Folder'), ), @@ -352,6 +350,14 @@ class _FolderNavigatorState extends ConsumerState { ], ), ), + // Cloud sync status floats over the library's corner so it never + // pushes the grid. + if (tab != LibraryTab.community) + const Positioned( + right: 16, + bottom: 16, + child: CloudOutboxSummaryBanner(), + ), if (_desktopUpdaterController != null) DesktopUpdateDialogListener( controller: _desktopUpdaterController!, diff --git a/lib/widgets/library_title_strip.dart b/lib/widgets/library_title_strip.dart index 075ee578..21345b61 100644 --- a/lib/widgets/library_title_strip.dart +++ b/lib/widgets/library_title_strip.dart @@ -18,6 +18,8 @@ import 'package:shadcn_ui/shadcn_ui.dart'; const double _controlHeight = 28; // Tabs sit apart so a selected and a hovered background never touch. const double _tabGap = 4; +// Matches the ghost button's own radius so the rim hugs its hover shape. +const double _tabRadius = 6; // Action menus hug their labels; the account menu keeps room for email text. const double _sortMenuWidth = 168; const double _newMenuWidth = 140; @@ -415,18 +417,24 @@ class _TabButton extends StatelessWidget { onTap: onTap, child: Opacity( opacity: dimmed ? 0.45 : 1, - child: ShadButton.ghost( - height: _controlHeight, - padding: const EdgeInsets.symmetric(horizontal: 8), - gap: 6, - backgroundColor: selected ? theme.secondary : null, - foregroundColor: foreground, - hoverForegroundColor: theme.foreground, - onPressed: onTap, - leading: Icon(icon, size: 15), - child: Text( - label, - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + child: DecoratedBox( + decoration: selected + ? Settings.raisedSurface(_tabRadius) + : const BoxDecoration(), + child: ShadButton.ghost( + height: _controlHeight, + padding: const EdgeInsets.symmetric(horizontal: 8), + gap: 6, + backgroundColor: selected ? Colors.transparent : null, + hoverBackgroundColor: selected ? Colors.transparent : null, + foregroundColor: foreground, + hoverForegroundColor: theme.foreground, + onPressed: onTap, + leading: Icon(icon, size: 15), + child: Text( + label, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + ), ), ), ), diff --git a/lib/widgets/line_up_placer.dart b/lib/widgets/line_up_placer.dart index 561332f8..51c2aceb 100644 --- a/lib/widgets/line_up_placer.dart +++ b/lib/widgets/line_up_placer.dart @@ -53,14 +53,7 @@ class _LineupPositionWidgetState extends ConsumerState { horizontal: 12, vertical: 8, ), - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.primary, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - ), - boxShadow: const [Settings.cardForegroundBackdrop], - ), + decoration: Settings.raisedPrimary(8), child: Text( "Drag an agent to the map to start placing", style: ShadTheme.of( diff --git a/lib/widgets/pages_bar.dart b/lib/widgets/pages_bar.dart index 6037e7f7..52cbc84b 100644 --- a/lib/widgets/pages_bar.dart +++ b/lib/widgets/pages_bar.dart @@ -508,7 +508,7 @@ class _CollapsedPill extends StatelessWidget { overflow: TextOverflow.ellipsis, style: theme.textTheme.titleMedium?.copyWith( color: Colors.white, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, fontSize: 14), ), ), @@ -904,9 +904,7 @@ class _PageRowState extends State<_PageRow> { final fillProgress = widget.transitionProgress?.clamp(0.0, 1.0); final showActions = _hovered || widget.active || widget.transitionProgress != null; - final bg = widget.active && fillProgress == null - ? Settings.tacticalVioletTheme.primary - : Settings.tacticalVioletTheme.card; + final isRaised = widget.active && fillProgress == null; return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), @@ -921,34 +919,48 @@ class _PageRowState extends State<_PageRow> { onTap: () => widget.onSelect(widget.page.id), child: ClipRRect( borderRadius: BorderRadius.circular(_PageRow._rowRadius), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(_PageRow._rowRadius), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - width: 1, - ), - boxShadow: [ - BoxShadow( - color: Settings.tacticalVioletTheme.card - .withValues(alpha: 0.2), - blurRadius: 12, - offset: const Offset(0, 4)) - ], - color: bg, - ), + // Every layer is a Positioned.fill over one fixed-size box, so + // the surface, the transition sweep, and the content share the + // same rect in every state. (A Container would inset its child + // by the resting border's width and shift everything 1px when + // the row turns active, which has no border.) + child: SizedBox( height: _PageRow._rowHeight, child: Stack( children: [ + Positioned.fill( + child: DecoratedBox( + decoration: isRaised + ? Settings.raisedPrimary(_PageRow._rowRadius) + : BoxDecoration( + borderRadius: + BorderRadius.circular(_PageRow._rowRadius), + border: Border.all( + color: Settings.tacticalVioletTheme.border, + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Settings.tacticalVioletTheme.card + .withValues(alpha: 0.2), + blurRadius: 12, + offset: const Offset(0, 4)) + ], + color: Settings.tacticalVioletTheme.card, + ), + ), + ), if (fillProgress != null) Positioned.fill( child: FractionallySizedBox( widthFactor: fillProgress, alignment: Alignment.centerLeft, + // The same surface as the active row, so the + // sweep's corners match at every width and it + // lands exactly on the active state. child: DecoratedBox( - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.primary, - ), + decoration: + Settings.raisedPrimary(_PageRow._rowRadius), ), ), ), @@ -963,9 +975,7 @@ class _PageRowState extends State<_PageRow> { overflow: TextOverflow.ellipsis, style: theme.textTheme.titleMedium?.copyWith( color: Colors.white, - fontWeight: widget.active - ? FontWeight.w600 - : FontWeight.w500, + fontWeight: FontWeight.w500, fontSize: 14, ), ), diff --git a/lib/widgets/selectable_icon_button.dart b/lib/widgets/selectable_icon_button.dart index b76ad8c3..e320018b 100644 --- a/lib/widgets/selectable_icon_button.dart +++ b/lib/widgets/selectable_icon_button.dart @@ -3,6 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +// The secondary icon button's own corner radius (the theme default). +const double _radius = 6; + class SelectableIconButton extends ConsumerWidget { const SelectableIconButton({ super.key, @@ -24,17 +27,24 @@ class SelectableIconButton extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final hasShortcutLabel = shortcutLabel != null && shortcutLabel!.isNotEmpty; + // A checked tool is a raised command surface. A caller-supplied color + // (the delete tools' red) stays a flat fill. + final raised = isSelected && hoverBackgroundColor == null; + final flatColor = + isSelected ? hoverBackgroundColor ?? Colors.transparent : null; Widget button = ShadIconButton.secondary( padding: EdgeInsets.zero, icon: icon, - backgroundColor: isSelected - ? hoverBackgroundColor ?? Settings.tacticalVioletTheme.primary - : null, - hoverBackgroundColor: isSelected - ? hoverBackgroundColor ?? Settings.tacticalVioletTheme.primary - : null, + backgroundColor: flatColor, + hoverBackgroundColor: flatColor, onPressed: onPressed, ); + if (raised) { + button = DecoratedBox( + decoration: Settings.raisedPrimary(_radius), + child: button, + ); + } // No tooltip text means no ShadTooltip wrapper; an empty tooltip bubble // would still pop up on hover otherwise. diff --git a/lib/widgets/strategy_quick_switcher.dart b/lib/widgets/strategy_quick_switcher.dart index 293fb49b..ba02d526 100644 --- a/lib/widgets/strategy_quick_switcher.dart +++ b/lib/widgets/strategy_quick_switcher.dart @@ -28,6 +28,12 @@ class StrategyQuickSwitcher extends ConsumerStatefulWidget { class _StrategyQuickSwitcherState extends ConsumerState { static const double _barWidth = 280; static const double _barHeight = 30; + static const double _barRadius = 8; + static const double _chevronWidth = 38; + // Menu geometry, matching the library strip's popovers. + static const double _menuRadius = 12; + static const double _menuInset = 6; + static const double _rowInset = 6; static const EdgeInsets _displayMargin = EdgeInsets.symmetric(horizontal: 16); final OverlayPortalController _controller = OverlayPortalController(); final LayerLink _layerLink = LayerLink(); @@ -297,73 +303,81 @@ class _StrategyQuickSwitcherState extends ConsumerState { left: left, top: top, width: _barWidth, - child: Material( - color: Colors.transparent, - child: Container( - constraints: BoxConstraints(maxHeight: maxHeight), - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.background, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - ), + // A floating menu like the library strip's: popover + // grey, hairline border, panel radius, one shadow. The + // rows inside are flat. + child: Container( + constraints: BoxConstraints(maxHeight: maxHeight), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.popover, + borderRadius: BorderRadius.circular(_menuRadius), + border: Border.all( + color: Settings.tacticalVioletTheme.border, ), - child: recents.isEmpty - ? Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - child: Text( - 'No recent strategies', - style: ShadTheme.of(context) - .textTheme - .small - .copyWith(color: Colors.white70), + boxShadow: const [Settings.floatingMenuShadow], + ), + child: recents.isEmpty + ? Padding( + padding: const EdgeInsets.fromLTRB( + _menuInset + _rowInset, + _menuInset + 6, + _menuInset + _rowInset, + _menuInset + 6, + ), + child: Text( + 'No recent strategies', + style: TextStyle( + fontSize: 12, + color: Settings + .tacticalVioletTheme.mutedForeground, ), - ) - : ListView.separated( - shrinkWrap: true, - padding: const EdgeInsets.all(8), - itemCount: recents.length, - separatorBuilder: (_, __) => - const SizedBox(height: 8), - itemBuilder: (context, index) { - final strategy = recents[index]; - final attackLabel = _attackLabel(strategy); - final mapName = _mapName(strategy); - final thumbnail = - 'assets/maps/thumbnails/${Maps.mapNames[strategy.mapData]}_thumbnail.webp'; - return _StrategyQuickSwitchItem( - strategyName: strategy.name, - mapName: mapName, - attackLabel: attackLabel, - attackColor: _attackColor(attackLabel), - lastEdited: _timeAgo(strategy.lastEdited), - thumbnailPath: thumbnail, - onTap: _isSwitching || _isEditingName - ? null - : () => _switchStrategy(strategy.id), - ); - }, ), - ), + ) + : ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.all(_menuInset), + itemCount: recents.length, + separatorBuilder: (_, __) => + const SizedBox(height: 2), + itemBuilder: (context, index) { + final strategy = recents[index]; + final attackLabel = _attackLabel(strategy); + final mapName = _mapName(strategy); + final thumbnail = + 'assets/maps/thumbnails/${Maps.mapNames[strategy.mapData]}_thumbnail.webp'; + return _StrategyQuickSwitchItem( + strategyName: strategy.name, + mapName: mapName, + attackLabel: attackLabel, + attackColor: _attackColor(attackLabel), + lastEdited: _timeAgo(strategy.lastEdited), + thumbnailPath: thumbnail, + onTap: _isSwitching || _isEditingName + ? null + : () => _switchStrategy(strategy.id), + ); + }, + ), ), ), ], ); }, + // A two-segment control. The children are clipped to the + // bar's inner rounded rect, so each segment's hover fill runs + // edge to edge and the bar's own corners round it off. child: Container( key: const ValueKey('strategy-quick-switcher-control'), width: _barWidth, height: _barHeight, decoration: BoxDecoration( color: Settings.tacticalVioletTheme.card, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(_barRadius), border: Border.all( color: Settings.tacticalVioletTheme.border, ), ), + clipBehavior: Clip.antiAlias, child: Row( children: [ Expanded( @@ -446,7 +460,8 @@ class _StrategyQuickSwitcherState extends ConsumerState { currentStrategy.strategyName == null ? SystemMouseCursors.basic : SystemMouseCursors.click, - borderRadius: BorderRadius.circular(8), + hoverColor: + Settings.tacticalVioletTheme.accent, child: Center( child: Padding( padding: const EdgeInsets.symmetric( @@ -470,12 +485,17 @@ class _StrategyQuickSwitcherState extends ConsumerState { ), Container( width: 1, - height: 30, color: Settings.tacticalVioletTheme.border, ), SizedBox( - width: 38, + width: _chevronWidth, child: ShadIconButton.ghost( + width: _chevronWidth, + height: double.infinity, + padding: EdgeInsets.zero, + decoration: const ShadDecoration( + border: ShadBorder(radius: BorderRadius.zero), + ), onPressed: _isSwitching || _isEditingName ? null : () => _isOpen ? _closePortal() : _openPortal(), @@ -531,106 +551,84 @@ class _StrategyQuickSwitchItem extends StatefulWidget { } class _StrategyQuickSwitchItemState extends State<_StrategyQuickSwitchItem> { - bool _isHovered = false; + static const double _rowHeight = 40; + static const double _thumbnail = 28; @override Widget build(BuildContext context) { - final isEnabled = widget.onTap != null; - final borderColor = _isHovered - ? Settings.tacticalVioletTheme.primary - : Settings.tacticalVioletTheme.border; - final backgroundColor = _isHovered - ? Settings.tacticalVioletTheme.card.withValues(alpha: 0.85) - : Settings.tacticalVioletTheme.card; - - return MouseRegion( - cursor: isEnabled ? SystemMouseCursors.click : SystemMouseCursors.basic, - onEnter: (_) => setState(() => _isHovered = true), - onExit: (_) => setState(() => _isHovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 120), - curve: Curves.easeOut, - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: borderColor), - boxShadow: const [Settings.cardForegroundBackdrop], + const theme = Settings.tacticalVioletTheme; + // A flat menu row: thumbnail, name over map, side over time. Hover is + // the ghost button's grey fill; nothing here is bordered or shadowed. + return ShadButton.ghost( + height: _rowHeight, + expands: true, + mainAxisAlignment: MainAxisAlignment.start, + padding: const EdgeInsets.symmetric( + horizontal: _StrategyQuickSwitcherState._rowInset, + ), + gap: 10, + onPressed: widget.onTap, + leading: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.asset( + widget.thumbnailPath, + width: _thumbnail, + height: _thumbnail, + fit: BoxFit.cover, ), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: widget.onTap, - borderRadius: BorderRadius.circular(10), - mouseCursor: - isEnabled ? SystemMouseCursors.click : SystemMouseCursors.basic, - hoverColor: - Settings.tacticalVioletTheme.primary.withValues(alpha: 0.12), - splashColor: - Settings.tacticalVioletTheme.primary.withValues(alpha: 0.2), - child: Padding( - padding: const EdgeInsets.all(6), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.asset( - widget.thumbnailPath, - width: 46, - height: 46, - fit: BoxFit.cover, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - OverflowTooltipText( - widget.strategyName, - style: ShadTheme.of(context).textTheme.small.copyWith( - color: Colors.white, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - widget.mapName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: ShadTheme.of(context).textTheme.small.copyWith( - color: Colors.white70, - ), - ), - ], - ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + OverflowTooltipText( + widget.strategyName, + style: TextStyle( + fontSize: 13, + height: 1.2, + color: theme.foreground, ), - const SizedBox(width: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.attackLabel, - style: ShadTheme.of(context).textTheme.small.copyWith( - color: widget.attackColor, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - widget.lastEdited, - style: ShadTheme.of(context).textTheme.small.copyWith( - color: Colors.white54, - ), - ), - ], + ), + Text( + widget.mapName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + height: 1.2, + color: theme.mutedForeground, ), - ], - ), + ), + ], ), ), - ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.attackLabel, + style: TextStyle( + fontSize: 12, + height: 1.2, + color: widget.attackColor, + ), + ), + Text( + widget.lastEdited, + style: TextStyle( + fontSize: 12, + height: 1.2, + color: theme.mutedForeground, + ), + ), + ], + ), + ], ), ); } diff --git a/lib/widgets/strategy_tile/strategy_tile.dart b/lib/widgets/strategy_tile/strategy_tile.dart index bf9fa8e9..1225c85b 100644 --- a/lib/widgets/strategy_tile/strategy_tile.dart +++ b/lib/widgets/strategy_tile/strategy_tile.dart @@ -123,8 +123,10 @@ class StrategyTileMenuActionSemantics extends StatelessWidget { } } +const double _ringWidth = 2; + class _StrategyTileState extends ConsumerState { - Color _highlightColor = Settings.tacticalVioletTheme.border; + bool _isHovered = false; bool _isLoading = false; bool _menuButtonWasOpenOnPointerDown = false; DropInsertionSide? _pinnedDropSide; @@ -266,10 +268,8 @@ class _StrategyTileState extends ConsumerState { ), child: MouseRegion( cursor: SystemMouseCursors.click, - onEnter: (_) => setState( - () => _highlightColor = Settings.tacticalVioletTheme.ring), - onExit: (_) => setState( - () => _highlightColor = Settings.tacticalVioletTheme.border), + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), child: AbsorbPointer( absorbing: _isLoading, child: ShadContextMenuRegion( @@ -290,41 +290,50 @@ class _StrategyTileState extends ConsumerState { return Stack( clipBehavior: Clip.none, children: [ + // The ring is the outer box showing through a + // 2px inset: flat zinc at rest, and on hover the + // lit violet gradient, since a Border cannot + // take a gradient. AnimatedContainer( - duration: const Duration(milliseconds: 100), - decoration: BoxDecoration( - color: ShadTheme.of(context).colorScheme.card, - borderRadius: BorderRadius.circular( - strategyTileOuterRadius), - border: Border.all( - color: isPinDropTarget - ? Settings.tacticalVioletTheme.border - : _highlightColor, - width: 2, + duration: const Duration(milliseconds: 100), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.border, + gradient: !isPinDropTarget && _isHovered + ? Settings.raisedPrimaryFill + : null, + borderRadius: BorderRadius.circular( + strategyTileOuterRadius), ), - ), - padding: const EdgeInsets.all(8), - child: Column( - children: [ - Expanded( - child: StrategyTileThumbnail( - assetPath: viewData.thumbnailAsset, - borderRadius: strategyTileInnerRadius, - overlay: widget.showDeviceBadge - ? const DeviceOnlyBadge() - : viewData.sharedBadge, - ), + padding: const EdgeInsets.all(_ringWidth), + child: Container( + decoration: BoxDecoration( + color: + ShadTheme.of(context).colorScheme.card, + borderRadius: BorderRadius.circular( + strategyTileOuterRadius - _ringWidth), ), - const SizedBox(height: 10), - Expanded( - child: StrategyTileDetails( - data: viewData, - borderRadius: strategyTileInnerRadius, - ), + padding: const EdgeInsets.all(8 - _ringWidth), + child: Column( + children: [ + Expanded( + child: StrategyTileThumbnail( + assetPath: viewData.thumbnailAsset, + borderRadius: strategyTileInnerRadius, + overlay: widget.showDeviceBadge + ? const DeviceOnlyBadge() + : viewData.sharedBadge, + ), + ), + const SizedBox(height: 10), + Expanded( + child: StrategyTileDetails( + data: viewData, + borderRadius: strategyTileInnerRadius, + ), + ), + ], ), - ], - ), - ), + )), if (isPinned) Align( alignment: Alignment.topLeft, @@ -364,8 +373,8 @@ class _StrategyTileState extends ConsumerState { width: 28, height: 28, onPressed: _handleMenuButtonPressed, - icon: - const Icon(LucideIcons.ellipsisVertical), + icon: const Icon( + LucideIcons.ellipsisVertical), ), ), ), @@ -454,8 +463,7 @@ class _StrategyTileState extends ConsumerState { leading: Icon(LucideIcons.trash2, color: Settings.tacticalVioletTheme.destructive), child: Text('Delete', - style: - TextStyle(color: Settings.tacticalVioletTheme.destructive)), + style: TextStyle(color: Settings.tacticalVioletTheme.destructive)), enabled: widget.canDelete, onPressed: widget.canDelete ? () { diff --git a/test/unsaved_strategy_guard_test.dart b/test/unsaved_strategy_guard_test.dart index b4f1608d..34403b01 100644 --- a/test/unsaved_strategy_guard_test.dart +++ b/test/unsaved_strategy_guard_test.dart @@ -521,7 +521,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Save changes?'), findsOneWidget); - await tester.tap(find.text("Don't Save")); + await tester.tap(find.text("Don't save")); await tester.pumpAndSettle(); final result = await guardFuture; @@ -617,13 +617,13 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Leave Anyway'), findsOneWidget); + expect(find.text('Leave anyway'), findsOneWidget); expect( find.textContaining('have not reached the cloud'), findsOneWidget, ); expect(opQueue.currentState.pending, hasLength(1)); - await tester.tap(find.text('Leave Anyway')); + await tester.tap(find.text('Leave anyway')); await tester.pumpAndSettle(); expect(await guardFuture, isTrue); @@ -674,8 +674,8 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Leave Anyway'), findsOneWidget); - await tester.tap(find.text('Leave Anyway')); + expect(find.text('Leave anyway'), findsOneWidget); + await tester.tap(find.text('Leave anyway')); await tester.pumpAndSettle(); expect(await guardFuture, isTrue); @@ -724,9 +724,9 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Leave Anyway'), findsNothing); - expect(find.text('Stay Here'), findsOneWidget); - await tester.tap(find.text('Stay Here')); + expect(find.text('Leave anyway'), findsNothing); + expect(find.text('Stay here'), findsOneWidget); + await tester.tap(find.text('Stay here')); await tester.pumpAndSettle(); expect(await guardFuture, isFalse); }); @@ -784,8 +784,8 @@ void main() { }, ); await tester.pumpAndSettle(); - expect(find.text('Leave Anyway'), findsNothing); - await tester.tap(find.text('Stay Here')); + expect(find.text('Leave anyway'), findsNothing); + await tester.tap(find.text('Stay here')); await tester.pumpAndSettle(); expect(await guarded, isFalse); expect(continued, isFalse); @@ -859,9 +859,9 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Leave Anyway'), findsOneWidget); + expect(find.text('Leave anyway'), findsOneWidget); expect(find.textContaining('will not delete'), findsOneWidget); - await tester.tap(find.text('Leave Anyway')); + await tester.tap(find.text('Leave anyway')); await tester.pumpAndSettle(); expect(await guardFuture, isTrue); expect(continueCalls, 1); @@ -926,8 +926,8 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Leave Anyway'), findsNothing); - await tester.tap(find.text('Stay Here')); + expect(find.text('Leave anyway'), findsNothing); + await tester.tap(find.text('Stay here')); await tester.pumpAndSettle(); expect(await guardFuture, isFalse); }); From a737cc98c2d9cd7c1fae891e635c10bfe07f03a1 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:52:32 -0400 Subject: [PATCH 25/27] Clear the analyzer so CI's analyze step passes One duplicate import in a test was the fatal warning. The rest are the mechanical infos dart fix applies: unnecessary imports, const constructors and literals, and child-last argument order. Co-Authored-By: Claude Fable 5.1 --- lib/main.dart | 3 +-- lib/providers/folder_provider.dart | 1 - lib/providers/strategy_page_session_provider.dart | 1 - lib/screenshot/screenshot_view.dart | 1 - lib/strategy/strategy_import_export.dart | 1 - lib/strategy_view.dart | 2 +- lib/widgets/editor_toolbar.dart | 1 - lib/widgets/folder_card.dart | 6 +++--- lib/widgets/pages_bar.dart | 1 - lib/widgets/settings_tab.dart | 1 - lib/widgets/strategy_save_icon_button.dart | 6 +++--- lib/widgets/strategy_tile/strategy_tile.dart | 9 ++++----- test/ability_anchor_scaling_test.dart | 2 +- test/action_history_hydration_test.dart | 1 - test/action_provider_bulk_clear_test.dart | 1 - test/custom_shape_color_test.dart | 2 +- test/custom_shape_indicator_test.dart | 1 - test/discord_presence_service_test.dart | 6 +++--- test/drawing_provider_test.dart | 1 - test/rectangle_axis_resize_geometry_test.dart | 2 +- test/screenshot_view_test.dart | 3 +-- test/strategy_integrity_test.dart | 1 - test/strategy_page_session_provider_test.dart | 1 - test/strategy_provider_save_queue_test.dart | 2 +- test/text_provider_test.dart | 1 - test/text_widget_resilience_test.dart | 1 - test/unsaved_strategy_guard_test.dart | 1 - 27 files changed, 20 insertions(+), 39 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 18d8c555..28cef0e4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -46,7 +46,6 @@ import 'package:icarus/services/discord_presence_service.dart'; import 'package:icarus/services/guarded_sign_out.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/strategy/strategy_migrator.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/startup/hive_store_launch.dart'; import 'package:icarus/strategy_view.dart'; import 'package:icarus/widgets/folder_navigator.dart'; @@ -573,7 +572,7 @@ class _MyAppState extends ConsumerState { primaryButtonTheme: ShadButtonTheme( decoration: ShadDecoration( gradient: Settings.raisedPrimaryFill, - shadows: [Settings.raisedDropShadow], + shadows: const [Settings.raisedDropShadow], border: const ShadBorder( radius: BorderRadius.all(Radius.circular(6)), top: ShadBorderSide(color: Settings.raisedTopLight, width: 1), diff --git a/lib/providers/folder_provider.dart b/lib/providers/folder_provider.dart index 492329bc..0aa33029 100644 --- a/lib/providers/folder_provider.dart +++ b/lib/providers/folder_provider.dart @@ -13,7 +13,6 @@ import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/services/cloud_library_action.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:uuid/uuid.dart'; diff --git a/lib/providers/strategy_page_session_provider.dart b/lib/providers/strategy_page_session_provider.dart index 7cca0823..cb20e04f 100644 --- a/lib/providers/strategy_page_session_provider.dart +++ b/lib/providers/strategy_page_session_provider.dart @@ -33,7 +33,6 @@ import 'package:icarus/providers/transition_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/providers/view_cone_geometry_provider.dart'; import 'package:icarus/strategy/strategy_page_apply.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/strategy/strategy_page_source.dart'; import 'package:icarus/view_cone/vision_geometry.dart'; diff --git a/lib/screenshot/screenshot_view.dart b/lib/screenshot/screenshot_view.dart index f40f5052..9c2ef4f9 100644 --- a/lib/screenshot/screenshot_view.dart +++ b/lib/screenshot/screenshot_view.dart @@ -15,7 +15,6 @@ import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/providers/screenshot_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; diff --git a/lib/strategy/strategy_import_export.dart b/lib/strategy/strategy_import_export.dart index 8b29f31e..a6b3128f 100644 --- a/lib/strategy/strategy_import_export.dart +++ b/lib/strategy/strategy_import_export.dart @@ -36,7 +36,6 @@ import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/services/archive_manifest.dart'; import 'package:icarus/strategy/strategy_migrator.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart index 41f47e09..127d8223 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -258,7 +258,7 @@ class _StrategyViewState extends ConsumerState ), // The canvas runs right up to the strip; each floating panel keeps // its own 8px of air so no bare band shows between the two. - Expanded( + const Expanded( child: Stack( clipBehavior: Clip.none, children: [ diff --git a/lib/widgets/editor_toolbar.dart b/lib/widgets/editor_toolbar.dart index 09c0d474..ea99b309 100644 --- a/lib/widgets/editor_toolbar.dart +++ b/lib/widgets/editor_toolbar.dart @@ -16,7 +16,6 @@ import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/services/cloud_strategy_export.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/screenshot/offscreen_capture.dart'; import 'package:icarus/screenshot/screenshot_view.dart'; diff --git a/lib/widgets/folder_card.dart b/lib/widgets/folder_card.dart index 20a0690c..f22ba372 100644 --- a/lib/widgets/folder_card.dart +++ b/lib/widgets/folder_card.dart @@ -698,7 +698,6 @@ class _FolderCardState extends ConsumerState ), ShadContextMenuItem( leading: const Icon(LucideIcons.pencil), - child: const Text('Edit'), enabled: _canManage, onPressed: !_canManage ? null @@ -715,6 +714,7 @@ class _FolderCardState extends ConsumerState }, ); }, + child: const Text('Edit'), ), if (_isCloud && widget.cloudRole == 'owner') ShadContextMenuItem( @@ -743,8 +743,6 @@ class _FolderCardState extends ConsumerState ShadContextMenuItem( leading: Icon(LucideIcons.trash2, color: Settings.tacticalVioletTheme.destructive), - child: Text('Delete', - style: TextStyle(color: Settings.tacticalVioletTheme.destructive)), enabled: _canManage, onPressed: !_canManage ? null @@ -759,6 +757,8 @@ class _FolderCardState extends ConsumerState ), ); }, + child: Text('Delete', + style: TextStyle(color: Settings.tacticalVioletTheme.destructive)), ), ]; } diff --git a/lib/widgets/pages_bar.dart b/lib/widgets/pages_bar.dart index 52cbc84b..e834fe5d 100644 --- a/lib/widgets/pages_bar.dart +++ b/lib/widgets/pages_bar.dart @@ -10,7 +10,6 @@ import 'package:icarus/providers/strategy_page_session_provider.dart' import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/transition_provider.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/widgets/custom_text_field.dart'; import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; diff --git a/lib/widgets/settings_tab.dart b/lib/widgets/settings_tab.dart index 3e3710f2..1fe9a71e 100644 --- a/lib/widgets/settings_tab.dart +++ b/lib/widgets/settings_tab.dart @@ -13,7 +13,6 @@ import 'package:icarus/providers/marker_sizes_sync.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/services/analytics_service.dart'; import 'package:icarus/services/guarded_sign_out.dart'; import 'package:icarus/widgets/account_avatar.dart'; diff --git a/lib/widgets/strategy_save_icon_button.dart b/lib/widgets/strategy_save_icon_button.dart index 13b2c825..2ae0644b 100644 --- a/lib/widgets/strategy_save_icon_button.dart +++ b/lib/widgets/strategy_save_icon_button.dart @@ -116,7 +116,7 @@ class _AutoSaveButtonState extends ConsumerState { final size = widget.style.iconSize; final Widget icon = switch (_phase) { - _Phase.idle => Icon(LucideIcons.save200, key: const ValueKey('idle')), + _Phase.idle => const Icon(LucideIcons.save200, key: ValueKey('idle')), _Phase.loading => SizedBox( key: const ValueKey('loading'), width: size - 2, @@ -128,9 +128,9 @@ class _AutoSaveButtonState extends ConsumerState { ), ), ), - _Phase.success => Icon( + _Phase.success => const Icon( LucideIcons.check200, - key: const ValueKey('success'), + key: ValueKey('success'), color: Settings.allyBGColor, ), }; diff --git a/lib/widgets/strategy_tile/strategy_tile.dart b/lib/widgets/strategy_tile/strategy_tile.dart index 1225c85b..bc7fa069 100644 --- a/lib/widgets/strategy_tile/strategy_tile.dart +++ b/lib/widgets/strategy_tile/strategy_tile.dart @@ -9,7 +9,6 @@ import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/services/cloud_strategy_export.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/strategy_view.dart'; import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; @@ -422,7 +421,6 @@ class _StrategyTileState extends ConsumerState { ), ShadContextMenuItem( leading: const Icon(LucideIcons.pencil), - child: const Text('Rename'), enabled: widget.canRename, onPressed: widget.canRename ? () { @@ -430,10 +428,10 @@ class _StrategyTileState extends ConsumerState { _showRenameDialog(); } : null, + child: const Text('Rename'), ), ShadContextMenuItem( leading: const Icon(LucideIcons.copy), - child: const Text('Duplicate'), enabled: widget.canDuplicate, onPressed: widget.canDuplicate ? () { @@ -441,6 +439,7 @@ class _StrategyTileState extends ConsumerState { _duplicateStrategy(); } : null, + child: const Text('Duplicate'), ), ShadContextMenuItem( leading: const Icon(LucideIcons.upload), @@ -462,8 +461,6 @@ class _StrategyTileState extends ConsumerState { ShadContextMenuItem( leading: Icon(LucideIcons.trash2, color: Settings.tacticalVioletTheme.destructive), - child: Text('Delete', - style: TextStyle(color: Settings.tacticalVioletTheme.destructive)), enabled: widget.canDelete, onPressed: widget.canDelete ? () { @@ -471,6 +468,8 @@ class _StrategyTileState extends ConsumerState { _showDeleteDialog(); } : null, + child: Text('Delete', + style: TextStyle(color: Settings.tacticalVioletTheme.destructive)), ), ]; } diff --git a/test/ability_anchor_scaling_test.dart b/test/ability_anchor_scaling_test.dart index d365fef0..bbe75b08 100644 --- a/test/ability_anchor_scaling_test.dart +++ b/test/ability_anchor_scaling_test.dart @@ -15,7 +15,7 @@ import 'package:icarus/providers/strategy_settings_provider.dart'; class _NoopStrategyProvider extends StrategyProvider { @override StrategyState build() { - return StrategyState( + return const StrategyState( isSaved: true, stratName: 'anchor-test', id: 'anchor-test', diff --git a/test/action_history_hydration_test.dart b/test/action_history_hydration_test.dart index 6f8eb6b5..c8250088 100644 --- a/test/action_history_hydration_test.dart +++ b/test/action_history_hydration_test.dart @@ -9,7 +9,6 @@ import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/text_provider.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; class _NoopStrategyProvider extends StrategyProvider { diff --git a/test/action_provider_bulk_clear_test.dart b/test/action_provider_bulk_clear_test.dart index 6c7e8fac..511b043c 100644 --- a/test/action_provider_bulk_clear_test.dart +++ b/test/action_provider_bulk_clear_test.dart @@ -16,7 +16,6 @@ import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; class _NoopStrategyProvider extends StrategyProvider { diff --git a/test/custom_shape_color_test.dart b/test/custom_shape_color_test.dart index ea8c3d65..8c473c22 100644 --- a/test/custom_shape_color_test.dart +++ b/test/custom_shape_color_test.dart @@ -9,7 +9,7 @@ import 'package:icarus/providers/utility_provider.dart'; class _NoopStrategyProvider extends StrategyProvider { @override StrategyState build() { - return StrategyState( + return const StrategyState( isSaved: true, stratName: null, id: 'shape-color-test', diff --git a/test/custom_shape_indicator_test.dart b/test/custom_shape_indicator_test.dart index f605f7bf..d0b9dc54 100644 --- a/test/custom_shape_indicator_test.dart +++ b/test/custom_shape_indicator_test.dart @@ -23,7 +23,6 @@ import 'package:icarus/widgets/draggable_widgets/utilities/placed_custom_circle_ import 'package:icarus/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/shape_indicator_fade.dart'; import 'package:icarus/widgets/mouse_watch.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; class _FixedMapProvider extends MapProvider { @override diff --git a/test/discord_presence_service_test.dart b/test/discord_presence_service_test.dart index dfc9ad5b..2df06797 100644 --- a/test/discord_presence_service_test.dart +++ b/test/discord_presence_service_test.dart @@ -8,7 +8,7 @@ void main() { group('DiscordPresenceData', () { test('uses a generic library presence when no strategy is open', () { final presence = DiscordPresenceData.fromAppState( - strategy: StrategyState( + strategy: const StrategyState( isSaved: true, stratName: null, id: 'testID', @@ -24,7 +24,7 @@ void main() { test('shares map and side without sharing the strategy name', () { final presence = DiscordPresenceData.fromAppState( - strategy: StrategyState( + strategy: const StrategyState( isSaved: false, stratName: 'Secret tournament execute', id: 'strategy-id', @@ -46,7 +46,7 @@ void main() { test('summarizes what is on the board', () { DiscordPresenceData build({int agents = 0, int abilities = 0}) => DiscordPresenceData.fromAppState( - strategy: StrategyState( + strategy: const StrategyState( isSaved: false, stratName: 'A-site rush', id: 'strategy-id', diff --git a/test/drawing_provider_test.dart b/test/drawing_provider_test.dart index 698c001d..56ba56c2 100644 --- a/test/drawing_provider_test.dart +++ b/test/drawing_provider_test.dart @@ -11,7 +11,6 @@ import 'package:icarus/const/traversal_speed.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; class _NoopStrategyProvider extends StrategyProvider { diff --git a/test/rectangle_axis_resize_geometry_test.dart b/test/rectangle_axis_resize_geometry_test.dart index 422ba4ef..a34c46eb 100644 --- a/test/rectangle_axis_resize_geometry_test.dart +++ b/test/rectangle_axis_resize_geometry_test.dart @@ -13,7 +13,7 @@ import 'package:icarus/widgets/draggable_widgets/utilities/rectangle_axis_resize class _NoopStrategyProvider extends StrategyProvider { @override StrategyState build() { - return StrategyState( + return const StrategyState( isSaved: true, stratName: null, id: 'axis-resize-test', diff --git a/test/screenshot_view_test.dart b/test/screenshot_view_test.dart index 71f7bfc7..2ea21326 100644 --- a/test/screenshot_view_test.dart +++ b/test/screenshot_view_test.dart @@ -16,7 +16,6 @@ import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/providers/pen_provider.dart'; import 'package:icarus/providers/screenshot_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_provider.dart'; @@ -256,7 +255,7 @@ void main() { testWidgets('pre-hydrated screenshot providers render without build writes', (tester) async { - final strategyState = StrategyState( + const strategyState = StrategyState( isSaved: true, stratName: 'test strategy', id: 'strategy-id', diff --git a/test/strategy_integrity_test.dart b/test/strategy_integrity_test.dart index b0431e68..8fac10f5 100644 --- a/test/strategy_integrity_test.dart +++ b/test/strategy_integrity_test.dart @@ -32,7 +32,6 @@ 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/strategy/strategy_migrator.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:path/path.dart' as path; class _IcaFixture { diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index c026a315..1cfd023a 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -31,7 +31,6 @@ import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/providers/transition_provider.dart' hide PageTransitionState; import 'package:icarus/providers/user_preferences_provider.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; class _FakeRemoteEditorNotifier extends RemoteEditorSnapshotNotifier { diff --git a/test/strategy_provider_save_queue_test.dart b/test/strategy_provider_save_queue_test.dart index 2523b9a8..5cd68ce4 100644 --- a/test/strategy_provider_save_queue_test.dart +++ b/test/strategy_provider_save_queue_test.dart @@ -10,7 +10,7 @@ class _ControlledSaveStrategyProvider extends StrategyProvider { int saveCalls = 0; @override - StrategyState build() => StrategyState( + StrategyState build() => const StrategyState( isSaved: false, stratName: 'Strategy', id: 'strategy-id', diff --git a/test/text_provider_test.dart b/test/text_provider_test.dart index 5772ad1a..3f6bd148 100644 --- a/test/text_provider_test.dart +++ b/test/text_provider_test.dart @@ -18,7 +18,6 @@ import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/providers/text_provider.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; class _NoopActionProvider extends ActionProvider { diff --git a/test/text_widget_resilience_test.dart b/test/text_widget_resilience_test.dart index 406248e8..d6c46498 100644 --- a/test/text_widget_resilience_test.dart +++ b/test/text_widget_resilience_test.dart @@ -21,7 +21,6 @@ import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/providers/text_provider.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/widgets/draggable_widgets/text/placed_text_builder.dart'; import 'package:icarus/widgets/draggable_widgets/text/text_widget.dart'; diff --git a/test/unsaved_strategy_guard_test.dart b/test/unsaved_strategy_guard_test.dart index 34403b01..c546f7b1 100644 --- a/test/unsaved_strategy_guard_test.dart +++ b/test/unsaved_strategy_guard_test.dart @@ -28,7 +28,6 @@ import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/services/unsaved_strategy_guard.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; From ba8bcc69ab52b05c136838d58fd1ad22f2728750 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:54:41 -0400 Subject: [PATCH 26/27] Let the sync popover's actions wrap Two short buttons fit in one row at the real font, but the test renderer's wide placeholder glyphs overflowed the 260px popover. A wrap keeps one row when it fits and folds when it does not. Co-Authored-By: Claude Fable 5.1 --- lib/widgets/cloud_sync_button.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/widgets/cloud_sync_button.dart b/lib/widgets/cloud_sync_button.dart index 84129ae5..6a923094 100644 --- a/lib/widgets/cloud_sync_button.dart +++ b/lib/widgets/cloud_sync_button.dart @@ -385,18 +385,18 @@ class _SyncStatusPopover extends StatelessWidget { if (status == _SyncStatus.attention && (!hasOtherStrategyAttention || hasActiveStrategyAttention)) ...[ const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.end, + Wrap( + alignment: WrapAlignment.end, + spacing: 8, + runSpacing: 8, children: [ - if (hasRejectedWork) ...[ + if (hasRejectedWork) ShadButton.secondary( size: ShadButtonSize.sm, expands: false, onPressed: isResolving ? null : onUseCloudVersions, child: const Text('Use cloud'), ), - const SizedBox(width: 8), - ], ShadButton( size: ShadButtonSize.sm, expands: false, From f36e02bbc2bfeb00b6f88ed77921a5e3adca30f9 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:11:05 -0400 Subject: [PATCH 27/27] Run the library strip semantics tests at a desktop width The strip's tabs and controls fit any real window, but the test renderer's wide placeholder font pushed them past the default 800px surface, so the three strip tests threw a RenderFlex overflow. They now use the same 1280x800 view the auth dialog test already does. Co-Authored-By: Claude Fable 5.1 --- .../cloud_beta_automation_semantics_test.dart | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/widgets/cloud_beta_automation_semantics_test.dart b/test/widgets/cloud_beta_automation_semantics_test.dart index da75ed68..f1ff1e18 100644 --- a/test/widgets/cloud_beta_automation_semantics_test.dart +++ b/test/widgets/cloud_beta_automation_semantics_test.dart @@ -107,6 +107,12 @@ void main() { testWidgets('library strip exposes stable destinations while signed out', (tester) async { + // The strip needs a desktop-width window; the test font is wide. + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(_testApp(_strip())); expect(find.byKey(const ValueKey('library-tab-library')), findsOneWidget); @@ -131,6 +137,12 @@ void main() { }); testWidgets('signed-out Shared tab opens login', (tester) async { + // The strip needs a desktop-width window; the test font is wide. + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(_testApp(_strip())); await tester.tap(find.byKey(const ValueKey('library-tab-shared'))); @@ -141,6 +153,12 @@ void main() { }); testWidgets('New menu offers a strategy and a folder', (tester) async { + // The strip needs a desktop-width window; the test font is wide. + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var created = 0; await tester.pumpWidget( _testApp(_strip(onCreateStrategy: () => created++)), @@ -158,6 +176,12 @@ void main() { expect(created, 1); }); testWidgets('library account action uses guarded sign out', (tester) async { + // The strip needs a desktop-width window; the test font is wide. + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var requests = 0; await tester.pumpWidget( ProviderScope(