Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
138 changes: 138 additions & 0 deletions lib/const/release_notes.dart
Original file line number Diff line number Diff line change
@@ -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<ReleaseNoteChange> 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<Map<String, dynamic>?> Function()? fetchManifestOverride;

static Future<List<ReleaseNotesEntry>> fetch() async {
final manifest = await _fetchManifest();
if (manifest == null) {
throw const ReleaseNotesUnavailable();
}
return parse(manifest);
}

static Future<Map<String, dynamic>?> _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<String, dynamic>;
} 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<ReleaseNotesEntry> parse(Map<String, dynamic> manifest) {
final rawItems = manifest['items'];
if (rawItems is! List) return const [];

final byVersionName = <String, ReleaseNotesEntry>{};
for (final rawItem in rawItems) {
if (rawItem is! Map) continue;
final item = Map<String, dynamic>.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<String, dynamic> 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;

Comment on lines +95 to +102

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Malformed versions reach the release dialog

A manifest entry only needs a non-empty version string and an integer build number to be accepted. A value such as not-a-semver-release with shortVersion: 999 is shown as an available release, and the release header overflows because arbitrary version text shares a non-wrapping row with the badge and date. Validate the updater version format before constructing an entry, and constrain or ellipsize the version label in the dialog.

T-Rex Ran code and verified through T-Rex

final rawChanges = item['changes'];
final changes = <ReleaseNoteChange>[];
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.';
}
54 changes: 54 additions & 0 deletions lib/providers/desktop_update_provider.dart
Original file line number Diff line number Diff line change
@@ -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<WindowsDesktopUpdateController?>((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;
});
6 changes: 6 additions & 0 deletions lib/providers/release_notes_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:icarus/const/release_notes.dart';

final releaseNotesProvider = FutureProvider<List<ReleaseNotesEntry>>((ref) {
return ReleaseNotes.fetch();
});
108 changes: 59 additions & 49 deletions lib/widgets/desktop_update_dialog.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -51,14 +52,7 @@ class _DesktopUpdateDialogListenerState

_dialogOpen = true;

await showShadDialog<void>(
context: context,
barrierDismissible: !widget.controller.isMandatory,
builder: (context) => DesktopUpdateDialog(
controller: widget.controller,
),
variant: ShadDialogVariant.alert,
);
await DesktopUpdateDialog.show(context, widget.controller);

if (!mounted) {
return;
Expand All @@ -85,6 +79,18 @@ class DesktopUpdateDialog extends StatelessWidget {
static const double _width = 420;
static const double _heroHeight = 180;

static Future<void> show(
BuildContext context,
WindowsDesktopUpdateController controller,
) {
return showShadDialog<void>(
context: context,
barrierDismissible: !controller.isMandatory,
builder: (context) => DesktopUpdateDialog(controller: controller),
variant: ShadDialogVariant.alert,
);
}

@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
Expand All @@ -95,6 +101,8 @@ class DesktopUpdateDialog extends StatelessWidget {
final bool canDismiss = !controller.isMandatory;
final notes = (controller.releaseNotes ?? const <ChangeModel?>[])
.whereType<ChangeModel>()
.map((note) =>
ReleaseNoteChange(message: note.message, type: note.type))
.toList();

final double fireProgress = controller.isDownloaded
Expand Down Expand Up @@ -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),
Expand All @@ -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<ChangeModel> notes;
final List<ReleaseNoteChange> 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,
),
],
),
),
),
],
),
),
],
),
),
],
);
}

Expand Down
Loading
Loading