diff --git a/DESIGN.md b/DESIGN.md index 9fa8aa8e..e6325343 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -16,7 +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. -- 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/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..56b100b8 --- /dev/null +++ b/lib/widgets/dialogs/release_notes_dialog.dart @@ -0,0 +1,159 @@ +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 = 520; + static const double _bodyHeight = 460; + + 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"), + 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) { + 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 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}); + + final ReleaseNotesEntry entry; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Container( + decoration: BoxDecoration( + color: theme.colorScheme.secondary, + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.fromLTRB(16, 14, 16, 2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + 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.date!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + fontSize: 12, + 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/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 new file mode 100644 index 00000000..4cc5eae3 --- /dev/null +++ b/lib/widgets/strip_status_icons.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +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'; + +const double kStripIconSize = 28; + +/// A ghost icon sized for the window strip. +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: kStripIconSize, + height: kStripIconSize, + foregroundColor: foregroundColor ?? theme.mutedForeground, + hoverForegroundColor: theme.foreground, + onPressed: onPressed, + icon: Icon(icon, size: 16), + ), + ); + } +} + +/// 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) { + final desktopController = ref.watch(desktopUpdateControllerProvider); + if (desktopController != null) { + return ListenableBuilder( + listenable: desktopController, + builder: (context, _) { + if (!desktopController.needUpdate) return const SizedBox.shrink(); + return _icon( + () => DesktopUpdateDialog.show(context, desktopController), + ); + }, + ); + } + + final status = ref.watch(appUpdateStatusProvider).valueOrNull; + if (status == null || !status.isUpdateAvailable) { + return const SizedBox.shrink(); + } + return _icon(() => UpdateChecker.showUpdateDialog(context, status)); + } + + 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 47d92640..6f115e51 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: UpdateAvailableIcon()), const WindowCaptionButtons(), ], ), diff --git a/test/release_notes_test.dart b/test/release_notes_test.dart new file mode 100644 index 00000000..273b3f4e --- /dev/null +++ b/test/release_notes_test.dart @@ -0,0 +1,104 @@ +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', () { + const installed = ReleaseNotesEntry( + version: '${Settings.versionName}+${Settings.versionNumber}', + shortVersion: Settings.versionNumber, + changes: [], + ); + const newer = ReleaseNotesEntry( + version: 'x+${Settings.versionNumber + 1}', + shortVersion: Settings.versionNumber + 1, + changes: [], + ); + + 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', + ), ), ), ),