From 0e8959df6755777cf623ef02bd26e4bbfb38377f Mon Sep 17 00:00:00 2001 From: daraadedeji07 Date: Tue, 22 Sep 2026 02:40:17 +0000 Subject: [PATCH 1/7] Add strip status icons: update, what's new, about Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- DESIGN.md | 1 + lib/const/release_notes.dart | 138 +++++++++++++ lib/providers/desktop_update_provider.dart | 54 +++++ lib/providers/release_notes_provider.dart | 6 + lib/widgets/desktop_update_dialog.dart | 108 +++++----- lib/widgets/dialogs/release_notes_dialog.dart | 142 +++++++++++++ lib/widgets/folder_navigator.dart | 49 +---- lib/widgets/strip_status_icons.dart | 192 ++++++++++++++++++ lib/widgets/window_chrome.dart | 2 + test/release_notes_test.dart | 105 ++++++++++ test/strategy_view_skeleton_test.dart | 27 +-- 11 files changed, 720 insertions(+), 104 deletions(-) create mode 100644 lib/const/release_notes.dart create mode 100644 lib/providers/desktop_update_provider.dart create mode 100644 lib/providers/release_notes_provider.dart create mode 100644 lib/widgets/dialogs/release_notes_dialog.dart create mode 100644 lib/widgets/strip_status_icons.dart create mode 100644 test/release_notes_test.dart diff --git a/DESIGN.md b/DESIGN.md index 9fa8aa8e..ad652fe2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -16,6 +16,7 @@ The palette, theme, and sizing constants live in `lib/const/settings.dart`, with ## Window chrome - 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. +- Every strip ends with the same three ghost icons before the caption buttons (`lib/widgets/strip_status_icons.dart`): an update icon that exists only while an update is waiting, What's new (past patch notes), and About (version). They are drawn by `AppWindowStrip` itself, so screens never place them. - The library strip holds the three tabs on the left and only search, sort, and New on the right (there is no account yet). 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`). No status chips or labels in the editor. diff --git a/lib/const/release_notes.dart b/lib/const/release_notes.dart new file mode 100644 index 00000000..be58391d --- /dev/null +++ b/lib/const/release_notes.dart @@ -0,0 +1,138 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting; +import 'package:http/http.dart' as http; +import 'package:icarus/const/settings.dart'; + +/// One line of a release's patch notes. +class ReleaseNoteChange { + const ReleaseNoteChange({required this.message, this.type}); + + final String message; + + /// Free-form tag from the release metadata: `feature`, `fix`, `improvement`. + final String? type; +} + +/// One shipped version, as published in the updater manifest. +class ReleaseNotesEntry { + const ReleaseNotesEntry({ + required this.version, + required this.shortVersion, + required this.changes, + this.date, + }); + + /// Full version string, e.g. `4.6.2+102`. + final String version; + + /// Build number, e.g. `102`. Compares against [Settings.versionNumber]. + final int shortVersion; + final String? date; + final List changes; + + /// `4.6.2` from `4.6.2+102`. + String get versionName => version.split('+').first; + + bool get isInstalled => shortVersion == Settings.versionNumber; + bool get isNewerThanInstalled => shortVersion > Settings.versionNumber; +} + +/// Reads the patch notes of every shipped version from the same manifest the +/// desktop updater reads. Store and web builds read it too: it is only JSON. +class ReleaseNotes { + @visibleForTesting + static Future?> Function()? fetchManifestOverride; + + static Future> fetch() async { + final manifest = await _fetchManifest(); + if (manifest == null) { + throw const ReleaseNotesUnavailable(); + } + return parse(manifest); + } + + static Future?> _fetchManifest() async { + final override = fetchManifestOverride; + if (override != null) return override(); + + try { + final response = await http.get(Settings.desktopUpdaterArchiveUrl); + if (response.statusCode != 200) { + debugPrint('Failed to load release notes: ${response.statusCode}'); + return null; + } + return json.decode(response.body) as Map; + } catch (e) { + debugPrint('Error fetching release notes: $e'); + return null; + } + } + + /// Newest first. Rebuilds of the same version (`4.6.2+101`, `4.6.2+102`) + /// collapse into the highest build so a version reads as one release. + static List parse(Map manifest) { + final rawItems = manifest['items']; + if (rawItems is! List) return const []; + + final byVersionName = {}; + for (final rawItem in rawItems) { + if (rawItem is! Map) continue; + final item = Map.from(rawItem); + final entry = _parseEntry(item); + if (entry == null) continue; + final existing = byVersionName[entry.versionName]; + if (existing == null || entry.shortVersion > existing.shortVersion) { + byVersionName[entry.versionName] = entry; + } + } + + final entries = byVersionName.values.toList() + ..sort((a, b) => b.shortVersion.compareTo(a.shortVersion)); + return entries; + } + + static ReleaseNotesEntry? _parseEntry(Map item) { + final version = item['version']?.toString().trim(); + if (version == null || version.isEmpty) return null; + + final shortVersion = + _toInt(item['shortVersion']) ?? int.tryParse(version.split('+').last); + if (shortVersion == null) return null; + + final rawChanges = item['changes']; + final changes = []; + if (rawChanges is List) { + for (final rawChange in rawChanges) { + if (rawChange is! Map) continue; + final message = rawChange['message']?.toString().trim(); + if (message == null || message.isEmpty) continue; + changes.add(ReleaseNoteChange( + message: message, + type: rawChange['type']?.toString(), + )); + } + } + + final date = item['date']?.toString().trim(); + return ReleaseNotesEntry( + version: version, + shortVersion: shortVersion, + changes: changes, + date: date == null || date.isEmpty ? null : date, + ); + } + + static int? _toInt(dynamic value) { + if (value is int) return value; + if (value is String) return int.tryParse(value); + return null; + } +} + +class ReleaseNotesUnavailable implements Exception { + const ReleaseNotesUnavailable(); + + @override + String toString() => 'Release notes unavailable.'; +} diff --git a/lib/providers/desktop_update_provider.dart b/lib/providers/desktop_update_provider.dart new file mode 100644 index 00000000..fc9cd86f --- /dev/null +++ b/lib/providers/desktop_update_provider.dart @@ -0,0 +1,54 @@ +import 'dart:io'; + +import 'package:desktop_updater/desktop_updater.dart'; +import 'package:flutter/foundation.dart' show debugPrint, kDebugMode, kIsWeb; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/update_status_provider.dart'; +import 'package:icarus/services/windows_desktop_update_controller.dart'; + +const desktopUpdateLocalization = DesktopUpdateLocalization( + updateAvailableText: 'Update Available', + newVersionAvailableText: '{} {} is available', + newVersionLongText: + 'A desktop update is ready. Downloading will fetch {} MB of files.', + downloadText: 'Download Update', + restartText: 'Restart to update', + skipThisVersionText: 'Later', + warningTitleText: 'Restart Required', + restartWarningText: + 'Icarus needs to restart to finish installing the update. Unsaved changes will be lost. Restart now?', + warningCancelText: 'Not now', + warningConfirmText: 'Restart', +); + +/// The in-app updater for direct Windows installs. Null everywhere else: +/// the Store, macOS, and web update through their own channels, and the +/// Store check has to fail first before we know this is a direct install. +final desktopUpdateControllerProvider = + Provider((ref) { + if (kDebugMode && kDebugForceDesktopUpdateDialog) { + final controller = WindowsDesktopUpdateController.debugPreview( + localization: desktopUpdateLocalization, + ); + ref.onDispose(controller.dispose); + return controller; + } + + final status = ref.watch(appUpdateStatusProvider).valueOrNull; + if (status == null) return null; + + final bool isDirectWindowsInstall = + !kIsWeb && Platform.isWindows && !status.isSupported; + if (!isDirectWindowsInstall) return null; + + debugPrint( + 'Desktop updater channel: $kResolvedUpdateChannel | Manifest: ${Settings.desktopUpdaterArchiveUrl}', + ); + final controller = WindowsDesktopUpdateController( + appArchiveUrl: Settings.desktopUpdaterArchiveUrl, + localization: desktopUpdateLocalization, + ); + ref.onDispose(controller.dispose); + return controller; +}); diff --git a/lib/providers/release_notes_provider.dart b/lib/providers/release_notes_provider.dart new file mode 100644 index 00000000..365588d6 --- /dev/null +++ b/lib/providers/release_notes_provider.dart @@ -0,0 +1,6 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/release_notes.dart'; + +final releaseNotesProvider = FutureProvider>((ref) { + return ReleaseNotes.fetch(); +}); diff --git a/lib/widgets/desktop_update_dialog.dart b/lib/widgets/desktop_update_dialog.dart index 765e8981..9dea35f4 100644 --- a/lib/widgets/desktop_update_dialog.dart +++ b/lib/widgets/desktop_update_dialog.dart @@ -1,5 +1,6 @@ import 'package:desktop_updater/desktop_updater.dart'; import 'package:flutter/material.dart'; +import 'package:icarus/const/release_notes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/services/windows_desktop_update_controller.dart'; @@ -51,14 +52,7 @@ class _DesktopUpdateDialogListenerState _dialogOpen = true; - await showShadDialog( - context: context, - barrierDismissible: !widget.controller.isMandatory, - builder: (context) => DesktopUpdateDialog( - controller: widget.controller, - ), - variant: ShadDialogVariant.alert, - ); + await DesktopUpdateDialog.show(context, widget.controller); if (!mounted) { return; @@ -85,6 +79,18 @@ class DesktopUpdateDialog extends StatelessWidget { static const double _width = 420; static const double _heroHeight = 180; + static Future show( + BuildContext context, + WindowsDesktopUpdateController controller, + ) { + return showShadDialog( + context: context, + barrierDismissible: !controller.isMandatory, + builder: (context) => DesktopUpdateDialog(controller: controller), + variant: ShadDialogVariant.alert, + ); + } + @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); @@ -95,6 +101,8 @@ class DesktopUpdateDialog extends StatelessWidget { final bool canDismiss = !controller.isMandatory; final notes = (controller.releaseNotes ?? const []) .whereType() + .map((note) => + ReleaseNoteChange(message: note.message, type: note.type)) .toList(); final double fireProgress = controller.isDownloaded @@ -190,7 +198,12 @@ class DesktopUpdateDialog extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (notes.isNotEmpty) ...[ - _PatchNotes(notes: notes), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 280), + child: SingleChildScrollView( + child: PatchNotesList(notes: notes), + ), + ), const SizedBox(height: 18), ], _UpdateButton(controller: controller), @@ -208,54 +221,51 @@ class DesktopUpdateDialog extends StatelessWidget { } } -class _PatchNotes extends StatelessWidget { - const _PatchNotes({required this.notes}); +/// Patch notes as a bulleted column, each bullet tinted by the note's type. +/// Shared by the update dialog and the What's new dialog. +class PatchNotesList extends StatelessWidget { + const PatchNotesList({super.key, required this.notes}); - final List notes; + final List notes; @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); - return ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 280), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final note in notes) - Padding( - padding: const EdgeInsets.only(bottom: 14), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - margin: const EdgeInsets.only(top: 8), - height: 4, - width: 4, - decoration: BoxDecoration( - color: _colorForNoteType(theme, note.type) - .withValues(alpha: 0.55), - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - note.message, - style: theme.textTheme.small.copyWith( - color: theme.colorScheme.foreground, - fontWeight: FontWeight.w400, - height: 1.55, - ), - ), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final note in notes) + Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(top: 8), + height: 4, + width: 4, + decoration: BoxDecoration( + color: _colorForNoteType(theme, note.type) + .withValues(alpha: 0.55), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + note.message, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.foreground, + fontWeight: FontWeight.w400, + height: 1.55, ), - ], + ), ), - ), - ], - ), - ), + ], + ), + ), + ], ); } diff --git a/lib/widgets/dialogs/release_notes_dialog.dart b/lib/widgets/dialogs/release_notes_dialog.dart new file mode 100644 index 00000000..406870c0 --- /dev/null +++ b/lib/widgets/dialogs/release_notes_dialog.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/release_notes.dart'; +import 'package:icarus/providers/release_notes_provider.dart'; +import 'package:icarus/widgets/desktop_update_dialog.dart'; +import 'package:icarus/widgets/dot_matrix_loaders.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// Every shipped version's patch notes, newest first, with the installed +/// version marked. Read-only: updating stays with the update dialog. +class ReleaseNotesDialog extends ConsumerWidget { + const ReleaseNotesDialog({super.key}); + + static const double _width = 460; + static const double _bodyHeight = 420; + + static Future show(BuildContext context) { + return showShadDialog( + context: context, + builder: (context) => const ReleaseNotesDialog(), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final notes = ref.watch(releaseNotesProvider); + + return ShadDialog( + title: const Text("What's new"), + description: const Text('Everything that changed, release by release.'), + constraints: const BoxConstraints(maxWidth: _width), + child: SizedBox( + height: _bodyHeight, + child: notes.when( + loading: () => const Center(child: WingDotLoader()), + error: (_, __) => _Unavailable( + onRetry: () => ref.invalidate(releaseNotesProvider), + ), + data: (entries) => entries.isEmpty + ? _Unavailable( + onRetry: () => ref.invalidate(releaseNotesProvider), + ) + : _ReleaseList(entries: entries), + ), + ), + ); + } +} + +class _ReleaseList extends StatelessWidget { + const _ReleaseList({required this.entries}); + + final List entries; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return ListView.separated( + padding: const EdgeInsets.only(top: 8), + itemCount: entries.length, + separatorBuilder: (_, __) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Divider(height: 1, color: theme.colorScheme.border), + ), + itemBuilder: (context, index) { + final entry = entries[index]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + entry.versionName, + style: theme.textTheme.large.copyWith( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + if (entry.isInstalled) ...[ + const SizedBox(width: 8), + const ShadBadge.secondary(child: Text('Installed')), + ] else if (entry.isNewerThanInstalled) ...[ + const SizedBox(width: 8), + const ShadBadge(child: Text('Available')), + ], + const Spacer(), + if (entry.date != null) + Text( + entry.date!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + const SizedBox(height: 12), + PatchNotesList(notes: entry.changes), + ], + ); + }, + ); + } +} + +class _Unavailable extends StatelessWidget { + const _Unavailable({required this.onRetry}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.wifiOff, + size: 22, + color: theme.colorScheme.mutedForeground, + ), + const SizedBox(height: 12), + Text( + "Couldn't load patch notes. Check your connection and try again.", + textAlign: TextAlign.center, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 16), + ShadButton.secondary( + onPressed: onRetry, + leading: const Icon(LucideIcons.refreshCw, size: 16), + child: const Text('Try again'), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index 26b18770..e8a59a14 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -1,19 +1,18 @@ import 'dart:io'; -import 'package:desktop_updater/desktop_updater.dart'; -import 'package:flutter/foundation.dart' show debugPrint, kDebugMode, kIsWeb; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/update_checker.dart'; import 'package:icarus/main.dart'; +import 'package:icarus/providers/desktop_update_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/update_status_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; -import 'package:icarus/services/windows_desktop_update_controller.dart'; import 'package:icarus/strategy_view.dart'; import 'package:icarus/widgets/desktop_update_dialog.dart'; import 'package:icarus/widgets/demo_dialog.dart'; @@ -36,42 +35,19 @@ class FolderNavigator extends ConsumerStatefulWidget { class _FolderNavigatorState extends ConsumerState { bool _warnedOnce = false; bool _hasPromptedUpdateDialog = false; - WindowsDesktopUpdateController? _desktopUpdaterController; final ShadContextMenuController _backgroundMenuController = ShadContextMenuController(); @override void dispose() { _backgroundMenuController.dispose(); - _desktopUpdaterController?.dispose(); super.dispose(); } - static const _desktopUpdateLocalization = DesktopUpdateLocalization( - updateAvailableText: 'Update Available', - newVersionAvailableText: '{} {} is available', - newVersionLongText: - 'A desktop update is ready. Downloading will fetch {} MB of files.', - downloadText: 'Download Update', - restartText: 'Restart to update', - skipThisVersionText: 'Later', - warningTitleText: 'Restart Required', - restartWarningText: - 'Icarus needs to restart to finish installing the update. Unsaved changes will be lost. Restart now?', - warningCancelText: 'Not now', - warningConfirmText: 'Restart', - ); - @override void initState() { super.initState(); - if (kDebugMode && kDebugForceDesktopUpdateDialog) { - _desktopUpdaterController = WindowsDesktopUpdateController.debugPreview( - localization: _desktopUpdateLocalization, - ); - } - // Show the demo warning only once after the first frame on web. WidgetsBinding.instance.addPostFrameCallback((_) { if (!_warnedOnce) { @@ -202,19 +178,6 @@ class _FolderNavigatorState extends ConsumerState { return; } - final bool isDirectWindowsInstall = - !kIsWeb && Platform.isWindows && !result.isSupported; - if (isDirectWindowsInstall && _desktopUpdaterController == null) { - debugPrint( - 'Desktop updater channel: $kResolvedUpdateChannel | Manifest: ${Settings.desktopUpdaterArchiveUrl}', - ); - _desktopUpdaterController = WindowsDesktopUpdateController( - appArchiveUrl: Settings.desktopUpdaterArchiveUrl, - localization: _desktopUpdateLocalization, - ); - setState(() {}); - } - if (_hasPromptedUpdateDialog || !result.isUpdateAvailable) { return; } @@ -227,6 +190,8 @@ class _FolderNavigatorState extends ConsumerState { }); }); + final desktopUpdateController = ref.watch(desktopUpdateControllerProvider); + final double height = MediaQuery.sizeOf(context).height - 90; final Size playAreaSize = Size(height * (16 / 9), height); CoordinateSystem(playAreaSize: playAreaSize); @@ -312,10 +277,8 @@ class _FolderNavigatorState extends ConsumerState { ], ), ), - if (_desktopUpdaterController != null) - DesktopUpdateDialogListener( - controller: _desktopUpdaterController!, - ), + if (desktopUpdateController != null) + DesktopUpdateDialogListener(controller: desktopUpdateController), ], ); } diff --git a/lib/widgets/strip_status_icons.dart b/lib/widgets/strip_status_icons.dart new file mode 100644 index 00000000..18548bbc --- /dev/null +++ b/lib/widgets/strip_status_icons.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show Clipboard, ClipboardData; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/const/update_checker.dart'; +import 'package:icarus/providers/desktop_update_provider.dart'; +import 'package:icarus/providers/update_status_provider.dart'; +import 'package:icarus/widgets/desktop_update_dialog.dart'; +import 'package:icarus/widgets/dialogs/release_notes_dialog.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:url_launcher/url_launcher.dart' show launchUrl; + +/// The quiet end of every window strip: an update icon that only appears +/// when there is one to install, then What's new and About. Lives here so +/// the library and the editor never disagree about where they are. +class StripStatusIcons extends ConsumerWidget { + const StripStatusIcons({super.key}); + + static const double _size = 28; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const _UpdateIcon(), + _StripIcon( + key: const ValueKey('strip-whats-new'), + tooltip: "What's new", + icon: LucideIcons.inbox, + onPressed: () => ReleaseNotesDialog.show(context), + ), + const _AboutIcon(), + ], + ), + ); + } +} + +class _StripIcon extends StatelessWidget { + const _StripIcon({ + super.key, + required this.tooltip, + required this.icon, + required this.onPressed, + this.foregroundColor, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onPressed; + final Color? foregroundColor; + + @override + Widget build(BuildContext context) { + const theme = Settings.tacticalVioletTheme; + return ShadTooltip( + builder: (context) => Text(tooltip), + child: ShadIconButton.ghost( + width: StripStatusIcons._size, + height: StripStatusIcons._size, + foregroundColor: foregroundColor ?? theme.mutedForeground, + hoverForegroundColor: theme.foreground, + onPressed: onPressed, + icon: Icon(icon, size: 16), + ), + ); + } +} + +/// Shown only while an update is waiting. Direct Windows installs open the +/// in-app updater; Store and web installs open the same dialog the automatic +/// check shows, so the icon is a second chance at it, not a second design. +class _UpdateIcon extends ConsumerWidget { + const _UpdateIcon(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final desktopController = ref.watch(desktopUpdateControllerProvider); + if (desktopController != null) { + return ListenableBuilder( + listenable: desktopController, + builder: (context, _) { + if (!desktopController.needUpdate) return const SizedBox.shrink(); + return _StripIcon( + key: const ValueKey('strip-update-available'), + tooltip: 'Update available', + icon: LucideIcons.download, + foregroundColor: Settings.tacticalVioletTheme.primary, + onPressed: () => + DesktopUpdateDialog.show(context, desktopController), + ); + }, + ); + } + + final status = ref.watch(appUpdateStatusProvider).valueOrNull; + if (status == null || !status.isUpdateAvailable) { + return const SizedBox.shrink(); + } + return _StripIcon( + key: const ValueKey('strip-update-available'), + tooltip: 'Update available', + icon: LucideIcons.download, + foregroundColor: Settings.tacticalVioletTheme.primary, + onPressed: () => UpdateChecker.showUpdateDialog(context, status), + ); + } +} + +class _AboutIcon extends StatefulWidget { + const _AboutIcon(); + + @override + State<_AboutIcon> createState() => _AboutIconState(); +} + +class _AboutIconState extends State<_AboutIcon> { + final _popover = ShadPopoverController(); + + @override + void dispose() { + _popover.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return ShadPopover( + controller: _popover, + anchor: const ShadAnchorAuto(offset: Offset(0, 6)), + popover: (context) => SizedBox( + width: 220, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Icarus', + style: theme.textTheme.p.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + 'Version ${Settings.versionName} (${Settings.versionNumber})', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 12), + ShadButton.ghost( + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 8), + leading: const Icon(LucideIcons.copy, size: 14), + onPressed: () { + Clipboard.setData(const ClipboardData( + text: + 'Icarus ${Settings.versionName}+${Settings.versionNumber}', + )); + _popover.hide(); + Settings.showToast( + message: 'Version copied', + backgroundColor: Settings.tacticalVioletTheme.primary, + ); + }, + child: const Text('Copy version'), + ), + ShadButton.ghost( + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 8), + leading: const Icon(LucideIcons.messageCircle, size: 14), + onPressed: () { + _popover.hide(); + launchUrl(Settings.dicordLink); + }, + child: const Text('Join the Discord'), + ), + ], + ), + ), + child: _StripIcon( + key: const ValueKey('strip-about'), + tooltip: 'About Icarus', + icon: LucideIcons.info, + onPressed: _popover.toggle, + ), + ); + } +} diff --git a/lib/widgets/window_chrome.dart b/lib/widgets/window_chrome.dart index 47d92640..9612c674 100644 --- a/lib/widgets/window_chrome.dart +++ b/lib/widgets/window_chrome.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart' import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/widgets/strip_status_icons.dart'; import 'package:window_manager/window_manager.dart'; /// Height of the strip the app draws in place of the native title bar. Every @@ -205,6 +206,7 @@ class AppWindowStrip extends StatelessWidget { children: [ const MacTrafficLightInset(), Expanded(child: child), + const Center(child: StripStatusIcons()), const WindowCaptionButtons(), ], ), diff --git a/test/release_notes_test.dart b/test/release_notes_test.dart new file mode 100644 index 00000000..fafc4a60 --- /dev/null +++ b/test/release_notes_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/release_notes.dart'; +import 'package:icarus/const/settings.dart'; + +void main() { + group('ReleaseNotes.parse', () { + test('sorts newest first and keeps the highest build of a version', () { + final entries = ReleaseNotes.parse({ + 'items': [ + { + 'version': '4.6.1+99', + 'shortVersion': 99, + 'date': '2026-09-01', + 'changes': [ + {'message': 'Old thing', 'type': 'fix'}, + ], + }, + { + 'version': '4.6.2+101', + 'shortVersion': 101, + 'changes': [ + {'message': 'First cut'}, + ], + }, + { + 'version': '4.6.2+102', + 'shortVersion': 102, + 'date': '2026-09-20', + 'changes': [ + {'message': 'Signed installers', 'type': 'feature'}, + {'message': ' ', 'type': 'noise'}, + 'not a map', + ], + }, + ], + }); + + expect(entries.map((e) => e.version), ['4.6.2+102', '4.6.1+99']); + expect(entries.first.versionName, '4.6.2'); + expect(entries.first.date, '2026-09-20'); + expect(entries.first.changes.map((c) => c.message), [ + 'Signed installers', + ]); + expect(entries.first.changes.single.type, 'feature'); + expect(entries.last.date, '2026-09-01'); + }); + + test('derives the build from the version when shortVersion is missing', + () { + final entries = ReleaseNotes.parse({ + 'items': [ + {'version': '4.5.0+90', 'changes': []}, + {'version': '', 'shortVersion': 1}, + {'version': 'broken', 'changes': []}, + ], + }); + + expect(entries.length, 1); + expect(entries.single.shortVersion, 90); + expect(entries.single.changes, isEmpty); + }); + + test('returns nothing for a manifest without items', () { + expect(ReleaseNotes.parse({}), isEmpty); + expect(ReleaseNotes.parse({'items': 'nope'}), isEmpty); + }); + + test('marks the installed and newer builds', () { + final installed = ReleaseNotesEntry( + version: '${Settings.versionName}+${Settings.versionNumber}', + shortVersion: Settings.versionNumber, + changes: const [], + ); + final newer = ReleaseNotesEntry( + version: 'x+${Settings.versionNumber + 1}', + shortVersion: Settings.versionNumber + 1, + changes: const [], + ); + + expect(installed.isInstalled, isTrue); + expect(installed.isNewerThanInstalled, isFalse); + expect(newer.isInstalled, isFalse); + expect(newer.isNewerThanInstalled, isTrue); + }); + }); + + group('ReleaseNotes.fetch', () { + tearDown(() => ReleaseNotes.fetchManifestOverride = null); + + test('throws when the manifest cannot be fetched', () async { + ReleaseNotes.fetchManifestOverride = () async => null; + expect(ReleaseNotes.fetch(), throwsA(isA())); + }); + + test('parses the fetched manifest', () async { + ReleaseNotes.fetchManifestOverride = () async => { + 'items': [ + {'version': '1.0.0+1', 'shortVersion': 1, 'changes': []}, + ], + }; + final entries = await ReleaseNotes.fetch(); + expect(entries.single.version, '1.0.0+1'); + }); + }); +} diff --git a/test/strategy_view_skeleton_test.dart b/test/strategy_view_skeleton_test.dart index fd195b28..3678ace3 100644 --- a/test/strategy_view_skeleton_test.dart +++ b/test/strategy_view_skeleton_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/settings.dart'; @@ -18,19 +19,21 @@ void main() { addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( - ShadApp( - themeMode: ThemeMode.dark, - darkTheme: ShadThemeData( - brightness: Brightness.dark, - colorScheme: Settings.tacticalVioletTheme, - ), - home: const MediaQuery( - data: MediaQueryData( - size: Size(800, 630), - disableAnimations: true, + ProviderScope( + child: ShadApp( + themeMode: ThemeMode.dark, + darkTheme: ShadThemeData( + brightness: Brightness.dark, + colorScheme: Settings.tacticalVioletTheme, ), - child: StrategyViewSkeleton( - strategyName: 'SYNC BOUNDARY PROBE', + home: const MediaQuery( + data: MediaQueryData( + size: Size(800, 630), + disableAnimations: true, + ), + child: StrategyViewSkeleton( + strategyName: 'SYNC BOUNDARY PROBE', + ), ), ), ), From c0999f0b1972bb5f9c289783e4c6b8bb6ed8126a Mon Sep 17 00:00:00 2001 From: daraadedeji07 Date: Tue, 22 Sep 2026 02:40:47 +0000 Subject: [PATCH 2/7] const Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- test/release_notes_test.dart | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/release_notes_test.dart b/test/release_notes_test.dart index fafc4a60..779cf7a0 100644 --- a/test/release_notes_test.dart +++ b/test/release_notes_test.dart @@ -45,8 +45,7 @@ void main() { expect(entries.last.date, '2026-09-01'); }); - test('derives the build from the version when shortVersion is missing', - () { + test('derives the build from the version when shortVersion is missing', () { final entries = ReleaseNotes.parse({ 'items': [ {'version': '4.5.0+90', 'changes': []}, @@ -66,12 +65,12 @@ void main() { }); test('marks the installed and newer builds', () { - final installed = ReleaseNotesEntry( + const installed = ReleaseNotesEntry( version: '${Settings.versionName}+${Settings.versionNumber}', shortVersion: Settings.versionNumber, changes: const [], ); - final newer = ReleaseNotesEntry( + const newer = ReleaseNotesEntry( version: 'x+${Settings.versionNumber + 1}', shortVersion: Settings.versionNumber + 1, changes: const [], From 014db65db2f1e78e309d89aea612d5be4c8d27f7 Mon Sep 17 00:00:00 2001 From: daraadedeji07 Date: Tue, 22 Sep 2026 02:41:04 +0000 Subject: [PATCH 3/7] Drop redundant const Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- test/release_notes_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/release_notes_test.dart b/test/release_notes_test.dart index 779cf7a0..273b3f4e 100644 --- a/test/release_notes_test.dart +++ b/test/release_notes_test.dart @@ -68,12 +68,12 @@ void main() { const installed = ReleaseNotesEntry( version: '${Settings.versionName}+${Settings.versionNumber}', shortVersion: Settings.versionNumber, - changes: const [], + changes: [], ); const newer = ReleaseNotesEntry( version: 'x+${Settings.versionNumber + 1}', shortVersion: Settings.versionNumber + 1, - changes: const [], + changes: [], ); expect(installed.isInstalled, isTrue); From 6d0b53d72f2318e3515ba147aef85bc28165109a Mon Sep 17 00:00:00 2001 From: daraadedeji07 Date: Tue, 22 Sep 2026 20:39:56 +0000 Subject: [PATCH 4/7] Move What's new before library search, drop About icon; version lives in settings rail Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- DESIGN.md | 5 +- lib/widgets/library_title_strip.dart | 3 + lib/widgets/settings_tab.dart | 34 ++++++ lib/widgets/strip_status_icons.dart | 169 +++++++-------------------- lib/widgets/window_chrome.dart | 2 +- 5 files changed, 80 insertions(+), 133 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index ad652fe2..e6325343 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -16,8 +16,9 @@ The palette, theme, and sizing constants live in `lib/const/settings.dart`, with ## Window chrome - 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. -- Every strip ends with the same three ghost icons before the caption buttons (`lib/widgets/strip_status_icons.dart`): an update icon that exists only while an update is waiting, What's new (past patch notes), and About (version). They are drawn by `AppWindowStrip` itself, so screens never place them. -- The library strip holds the three tabs on the left and only search, sort, and New on the right (there is no account yet). Nothing else goes in it. Inside a folder, the breadcrumb lives in the content area, not the strip. +- An update icon exists only while an update is waiting, drawn by `AppWindowStrip` itself just before the caption buttons (`lib/widgets/strip_status_icons.dart`), so it shows on every screen and no screen places it. +- The library strip holds the three tabs on the left and only What's new (past patch notes), search, sort, and New on the right (there is no account yet). Nothing else goes in it. Inside a folder, the breadcrumb lives in the content area, not the strip. +- The app version lives at the foot of the settings navigation rail, muted; click copies it. It is not in any 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`). No status chips or labels in the editor. ## Icons diff --git a/lib/widgets/library_title_strip.dart b/lib/widgets/library_title_strip.dart index 9caa44e6..5249eb02 100644 --- a/lib/widgets/library_title_strip.dart +++ b/lib/widgets/library_title_strip.dart @@ -6,6 +6,7 @@ import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/strategy_filter_provider.dart'; import 'package:icarus/widgets/custom_search_field.dart'; import 'package:icarus/widgets/demo_tag.dart'; +import 'package:icarus/widgets/strip_status_icons.dart'; import 'package:icarus/widgets/window_chrome.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -103,6 +104,8 @@ class _LibraryTitleStripState extends ConsumerState { child: SizedBox.expand(), ), ), + const WhatsNewIcon(), + const SizedBox(width: 4), const SizedBox( height: _controlHeight, child: SearchTextField( diff --git a/lib/widgets/settings_tab.dart b/lib/widgets/settings_tab.dart index ed3d0143..46f7e198 100644 --- a/lib/widgets/settings_tab.dart +++ b/lib/widgets/settings_tab.dart @@ -1086,12 +1086,46 @@ class _SettingsNavigationRail extends StatelessWidget { isSelected: selectedSection == _SettingsSection.shortcuts, onTap: () => onSectionSelected(_SettingsSection.shortcuts), ), + const Spacer(), + const _VersionFooter(), ], ), ); } } +/// The build number, tucked under the navigation. Click copies it for bug +/// reports. +class _VersionFooter extends StatelessWidget { + const _VersionFooter(); + + static const String _label = + 'Icarus ${Settings.versionName} (${Settings.versionNumber})'; + + @override + Widget build(BuildContext context) { + const theme = Settings.tacticalVioletTheme; + return ShadTooltip( + builder: (context) => const Text('Copy version'), + child: ShadButton.ghost( + key: const ValueKey('settings-version'), + height: 24, + padding: const EdgeInsets.symmetric(horizontal: 8), + foregroundColor: theme.mutedForeground, + hoverForegroundColor: theme.foreground, + onPressed: () { + Clipboard.setData(const ClipboardData(text: _label)); + Settings.showToast( + message: 'Version copied', + backgroundColor: theme.primary, + ); + }, + child: const Text(_label, style: TextStyle(fontSize: 11)), + ), + ); + } +} + class _SettingsNavHeader extends StatelessWidget { const _SettingsNavHeader({required this.label}); diff --git a/lib/widgets/strip_status_icons.dart b/lib/widgets/strip_status_icons.dart index 18548bbc..4cc5eae3 100644 --- a/lib/widgets/strip_status_icons.dart +++ b/lib/widgets/strip_status_icons.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/update_checker.dart'; @@ -8,39 +7,12 @@ import 'package:icarus/providers/update_status_provider.dart'; import 'package:icarus/widgets/desktop_update_dialog.dart'; import 'package:icarus/widgets/dialogs/release_notes_dialog.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; -import 'package:url_launcher/url_launcher.dart' show launchUrl; -/// The quiet end of every window strip: an update icon that only appears -/// when there is one to install, then What's new and About. Lives here so -/// the library and the editor never disagree about where they are. -class StripStatusIcons extends ConsumerWidget { - const StripStatusIcons({super.key}); +const double kStripIconSize = 28; - static const double _size = 28; - - @override - Widget build(BuildContext context, WidgetRef ref) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const _UpdateIcon(), - _StripIcon( - key: const ValueKey('strip-whats-new'), - tooltip: "What's new", - icon: LucideIcons.inbox, - onPressed: () => ReleaseNotesDialog.show(context), - ), - const _AboutIcon(), - ], - ), - ); - } -} - -class _StripIcon extends StatelessWidget { - const _StripIcon({ +/// A ghost icon sized for the window strip. +class StripIcon extends StatelessWidget { + const StripIcon({ super.key, required this.tooltip, required this.icon, @@ -59,8 +31,8 @@ class _StripIcon extends StatelessWidget { return ShadTooltip( builder: (context) => Text(tooltip), child: ShadIconButton.ghost( - width: StripStatusIcons._size, - height: StripStatusIcons._size, + width: kStripIconSize, + height: kStripIconSize, foregroundColor: foregroundColor ?? theme.mutedForeground, hoverForegroundColor: theme.foreground, onPressed: onPressed, @@ -70,11 +42,27 @@ class _StripIcon extends StatelessWidget { } } -/// Shown only while an update is waiting. Direct Windows installs open the -/// in-app updater; Store and web installs open the same dialog the automatic -/// check shows, so the icon is a second chance at it, not a second design. -class _UpdateIcon extends ConsumerWidget { - const _UpdateIcon(); +/// Opens every shipped version's patch notes. +class WhatsNewIcon extends StatelessWidget { + const WhatsNewIcon({super.key}); + + @override + Widget build(BuildContext context) { + return StripIcon( + key: const ValueKey('strip-whats-new'), + tooltip: "What's new", + icon: LucideIcons.inbox, + onPressed: () => ReleaseNotesDialog.show(context), + ); + } +} + +/// Exists only while an update is waiting, on every window strip. Direct +/// Windows installs open the in-app updater; Store and web installs reopen +/// the dialog the automatic check shows, so it is a second chance at the +/// same thing, not a second design. +class UpdateAvailableIcon extends ConsumerWidget { + const UpdateAvailableIcon({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -84,13 +72,8 @@ class _UpdateIcon extends ConsumerWidget { listenable: desktopController, builder: (context, _) { if (!desktopController.needUpdate) return const SizedBox.shrink(); - return _StripIcon( - key: const ValueKey('strip-update-available'), - tooltip: 'Update available', - icon: LucideIcons.download, - foregroundColor: Settings.tacticalVioletTheme.primary, - onPressed: () => - DesktopUpdateDialog.show(context, desktopController), + return _icon( + () => DesktopUpdateDialog.show(context, desktopController), ); }, ); @@ -100,92 +83,18 @@ class _UpdateIcon extends ConsumerWidget { if (status == null || !status.isUpdateAvailable) { return const SizedBox.shrink(); } - return _StripIcon( - key: const ValueKey('strip-update-available'), - tooltip: 'Update available', - icon: LucideIcons.download, - foregroundColor: Settings.tacticalVioletTheme.primary, - onPressed: () => UpdateChecker.showUpdateDialog(context, status), - ); - } -} - -class _AboutIcon extends StatefulWidget { - const _AboutIcon(); - - @override - State<_AboutIcon> createState() => _AboutIconState(); -} - -class _AboutIconState extends State<_AboutIcon> { - final _popover = ShadPopoverController(); - - @override - void dispose() { - _popover.dispose(); - super.dispose(); + return _icon(() => UpdateChecker.showUpdateDialog(context, status)); } - @override - Widget build(BuildContext context) { - final theme = ShadTheme.of(context); - return ShadPopover( - controller: _popover, - anchor: const ShadAnchorAuto(offset: Offset(0, 6)), - popover: (context) => SizedBox( - width: 220, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Icarus', - style: theme.textTheme.p.copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 4), - Text( - 'Version ${Settings.versionName} (${Settings.versionNumber})', - style: theme.textTheme.small.copyWith( - color: theme.colorScheme.mutedForeground, - fontWeight: FontWeight.w400, - ), - ), - const SizedBox(height: 12), - ShadButton.ghost( - height: 28, - padding: const EdgeInsets.symmetric(horizontal: 8), - leading: const Icon(LucideIcons.copy, size: 14), - onPressed: () { - Clipboard.setData(const ClipboardData( - text: - 'Icarus ${Settings.versionName}+${Settings.versionNumber}', - )); - _popover.hide(); - Settings.showToast( - message: 'Version copied', - backgroundColor: Settings.tacticalVioletTheme.primary, - ); - }, - child: const Text('Copy version'), - ), - ShadButton.ghost( - height: 28, - padding: const EdgeInsets.symmetric(horizontal: 8), - leading: const Icon(LucideIcons.messageCircle, size: 14), - onPressed: () { - _popover.hide(); - launchUrl(Settings.dicordLink); - }, - child: const Text('Join the Discord'), - ), - ], - ), - ), - child: _StripIcon( - key: const ValueKey('strip-about'), - tooltip: 'About Icarus', - icon: LucideIcons.info, - onPressed: _popover.toggle, + Widget _icon(VoidCallback onPressed) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: StripIcon( + key: const ValueKey('strip-update-available'), + tooltip: 'Update available', + icon: LucideIcons.download, + foregroundColor: Settings.tacticalVioletTheme.primary, + onPressed: onPressed, ), ); } diff --git a/lib/widgets/window_chrome.dart b/lib/widgets/window_chrome.dart index 9612c674..6f115e51 100644 --- a/lib/widgets/window_chrome.dart +++ b/lib/widgets/window_chrome.dart @@ -206,7 +206,7 @@ class AppWindowStrip extends StatelessWidget { children: [ const MacTrafficLightInset(), Expanded(child: child), - const Center(child: StripStatusIcons()), + const Center(child: UpdateAvailableIcon()), const WindowCaptionButtons(), ], ), From 1bc54f07486e377648b86844c36c8e61b22521cf Mon Sep 17 00:00:00 2001 From: daraadedeji07 Date: Tue, 22 Sep 2026 21:01:36 +0000 Subject: [PATCH 5/7] What's new: one card per release Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- lib/widgets/dialogs/release_notes_dialog.dart | 65 +++++++++++++------ 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/lib/widgets/dialogs/release_notes_dialog.dart b/lib/widgets/dialogs/release_notes_dialog.dart index 406870c0..8cfe57ba 100644 --- a/lib/widgets/dialogs/release_notes_dialog.dart +++ b/lib/widgets/dialogs/release_notes_dialog.dart @@ -11,8 +11,8 @@ import 'package:shadcn_ui/shadcn_ui.dart'; class ReleaseNotesDialog extends ConsumerWidget { const ReleaseNotesDialog({super.key}); - static const double _width = 460; - static const double _bodyHeight = 420; + static const double _width = 520; + static const double _bodyHeight = 460; static Future show(BuildContext context) { return showShadDialog( @@ -52,22 +52,41 @@ class _ReleaseList extends StatelessWidget { final List entries; + @override + Widget build(BuildContext context) { + return ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: ListView.separated( + padding: const EdgeInsets.only(top: 8, bottom: 4), + itemCount: entries.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) => _ReleaseCard(entry: entries[index]), + ), + ); + } +} + +/// One release: version, date, and its notes on a card surface. +class _ReleaseCard extends StatelessWidget { + const _ReleaseCard({required this.entry}); + + final ReleaseNotesEntry entry; + @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); - return ListView.separated( - padding: const EdgeInsets.only(top: 8), - itemCount: entries.length, - separatorBuilder: (_, __) => Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Divider(height: 1, color: theme.colorScheme.border), + return Container( + decoration: BoxDecoration( + color: theme.colorScheme.card, + border: Border.all(color: theme.colorScheme.border), + borderRadius: BorderRadius.circular(16), ), - itemBuilder: (context, index) { - final entry = entries[index]; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 12), + child: Row( children: [ Text( entry.versionName, @@ -77,10 +96,10 @@ class _ReleaseList extends StatelessWidget { ), ), if (entry.isInstalled) ...[ - const SizedBox(width: 8), + const SizedBox(width: 10), const ShadBadge.secondary(child: Text('Installed')), ] else if (entry.isNewerThanInstalled) ...[ - const SizedBox(width: 8), + const SizedBox(width: 10), const ShadBadge(child: Text('Available')), ], const Spacer(), @@ -89,16 +108,20 @@ class _ReleaseList extends StatelessWidget { entry.date!, style: theme.textTheme.small.copyWith( color: theme.colorScheme.mutedForeground, + fontSize: 12, fontWeight: FontWeight.w400, ), ), ], ), - const SizedBox(height: 12), - PatchNotesList(notes: entry.changes), - ], - ); - }, + ), + Divider(height: 1, color: theme.colorScheme.border), + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 2), + child: PatchNotesList(notes: entry.changes), + ), + ], + ), ); } } From 02084c38d52e14dff922240956d7becdf4303752 Mon Sep 17 00:00:00 2001 From: daraadedeji07 Date: Tue, 22 Sep 2026 21:13:39 +0000 Subject: [PATCH 6/7] What's new: raised cards, no lines Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- lib/widgets/dialogs/release_notes_dialog.dart | 69 +++++++++---------- 1 file changed, 31 insertions(+), 38 deletions(-) diff --git a/lib/widgets/dialogs/release_notes_dialog.dart b/lib/widgets/dialogs/release_notes_dialog.dart index 8cfe57ba..08b64602 100644 --- a/lib/widgets/dialogs/release_notes_dialog.dart +++ b/lib/widgets/dialogs/release_notes_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/release_notes.dart'; +import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/release_notes_provider.dart'; import 'package:icarus/widgets/desktop_update_dialog.dart'; import 'package:icarus/widgets/dot_matrix_loaders.dart'; @@ -66,7 +67,8 @@ class _ReleaseList extends StatelessWidget { } } -/// One release: version, date, and its notes on a card surface. +/// One release: version, date, and its notes on a raised surface, one step +/// above the dialog sheet. No lines; the lift does the separating. class _ReleaseCard extends StatelessWidget { const _ReleaseCard({required this.entry}); @@ -76,50 +78,41 @@ class _ReleaseCard extends StatelessWidget { Widget build(BuildContext context) { final theme = ShadTheme.of(context); return Container( - decoration: BoxDecoration( - color: theme.colorScheme.card, - border: Border.all(color: theme.colorScheme.border), - borderRadius: BorderRadius.circular(16), - ), + decoration: Settings.raisedSurface(16), + padding: const EdgeInsets.fromLTRB(16, 14, 16, 2), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 12), - child: Row( - children: [ + Row( + children: [ + Text( + entry.versionName, + style: theme.textTheme.large.copyWith( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + if (entry.isInstalled) ...[ + const SizedBox(width: 10), + const ShadBadge.outline(child: Text('Installed')), + ] else if (entry.isNewerThanInstalled) ...[ + const SizedBox(width: 10), + const ShadBadge(child: Text('Available')), + ], + const Spacer(), + if (entry.date != null) Text( - entry.versionName, - style: theme.textTheme.large.copyWith( - fontSize: 16, - fontWeight: FontWeight.w600, + entry.date!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + fontSize: 12, + fontWeight: FontWeight.w400, ), ), - if (entry.isInstalled) ...[ - const SizedBox(width: 10), - const ShadBadge.secondary(child: Text('Installed')), - ] else if (entry.isNewerThanInstalled) ...[ - const SizedBox(width: 10), - const ShadBadge(child: Text('Available')), - ], - const Spacer(), - if (entry.date != null) - Text( - entry.date!, - style: theme.textTheme.small.copyWith( - color: theme.colorScheme.mutedForeground, - fontSize: 12, - fontWeight: FontWeight.w400, - ), - ), - ], - ), - ), - Divider(height: 1, color: theme.colorScheme.border), - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 2), - child: PatchNotesList(notes: entry.changes), + ], ), + const SizedBox(height: 12), + PatchNotesList(notes: entry.changes), ], ), ); From bd19a76ed58c97bf351244a2f185273095532af5 Mon Sep 17 00:00:00 2001 From: daraadedeji07 Date: Tue, 22 Sep 2026 23:17:44 +0000 Subject: [PATCH 7/7] What's new: flat cards, drop description Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- lib/widgets/dialogs/release_notes_dialog.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/widgets/dialogs/release_notes_dialog.dart b/lib/widgets/dialogs/release_notes_dialog.dart index 08b64602..56b100b8 100644 --- a/lib/widgets/dialogs/release_notes_dialog.dart +++ b/lib/widgets/dialogs/release_notes_dialog.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/release_notes.dart'; -import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/release_notes_provider.dart'; import 'package:icarus/widgets/desktop_update_dialog.dart'; import 'package:icarus/widgets/dot_matrix_loaders.dart'; @@ -28,7 +27,6 @@ class ReleaseNotesDialog extends ConsumerWidget { return ShadDialog( title: const Text("What's new"), - description: const Text('Everything that changed, release by release.'), constraints: const BoxConstraints(maxWidth: _width), child: SizedBox( height: _bodyHeight, @@ -67,8 +65,8 @@ class _ReleaseList extends StatelessWidget { } } -/// One release: version, date, and its notes on a raised surface, one step -/// above the dialog sheet. No lines; the lift does the separating. +/// One release: version, date, and its notes on a flat surface one step +/// above the dialog sheet. No lines; the tonal step does the separating. class _ReleaseCard extends StatelessWidget { const _ReleaseCard({required this.entry}); @@ -78,7 +76,10 @@ class _ReleaseCard extends StatelessWidget { Widget build(BuildContext context) { final theme = ShadTheme.of(context); return Container( - decoration: Settings.raisedSurface(16), + decoration: BoxDecoration( + color: theme.colorScheme.secondary, + borderRadius: BorderRadius.circular(16), + ), padding: const EdgeInsets.fromLTRB(16, 14, 16, 2), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch,