Changelog and What's new: CHANGELOG.md cut into each release, shown in the update band, a What's new card and once after updating
CHANGELOG.md keeps an Unreleased section (user-facing changes add a line in the same commit); tool/release.sh cuts it into the version, the manifest gets a structured "changes" field next to the one-line "notes" older apps read, and the Gitea release text is that section. The app's update band shows the top items, What's new opens a card (Added / Changed / Fixed), and the first start after an update shows it once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ec0849eb01
commit
638a30e16c
22 files changed
+1637
-58
No files matched your search
@@ -6,6 +6,8 @@ import '../../shell/mark_problem.dart';
|
||||
import '../../app/app_graph.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../../update/update_controller.dart';
|
||||
import '../../update/whats_new.dart';
|
||||
import '../../widgets/whats_new_card.dart';
|
||||
import '../../widgets/app_menu.dart';
|
||||
import '../../widgets/copy_open.dart';
|
||||
import 'settings_page.dart';
|
||||
@@ -16,11 +18,12 @@ import 'update_indicator.dart';
|
||||
const uoscCredit = 'Player timeline and controls inspired by uosc by tomasklaen, LGPL-2.1 '
|
||||
'(github.com/tomasklaen/uosc).';
|
||||
|
||||
/// Name, version, update state, the source link and the logs (Settings →
|
||||
/// About).
|
||||
/// Name, version, update state, What's new in this version, the source
|
||||
/// link and the logs (Settings → About).
|
||||
class AboutPanel extends StatelessWidget {
|
||||
final UpdateController? updates;
|
||||
const AboutPanel({super.key, this.updates});
|
||||
final WhatsNew? whatsNew;
|
||||
const AboutPanel({super.key, this.updates, this.whatsNew});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -36,6 +39,23 @@ class AboutPanel extends StatelessWidget {
|
||||
style: t.bodyMedium?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: 4),
|
||||
UpdateStatusLine(controller: updates),
|
||||
// This version's notes, once kept (the install, a check).
|
||||
ValueListenableBuilder(
|
||||
valueListenable: (updates ?? UpdateController.instance).latest,
|
||||
builder: (context, _, _) {
|
||||
final notes = (whatsNew ?? WhatsNew.instance).current;
|
||||
if (notes == null) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: FilledButton.tonalIcon(
|
||||
key: const ValueKey('about-whats-new'),
|
||||
onPressed: () => showWhatsNew(context, notes),
|
||||
icon: const Icon(Icons.auto_awesome, size: 18),
|
||||
label: Text("What's new in ${notes.version}"),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: kGap),
|
||||
Row(
|
||||
children: [
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart' show isDevBuild;
|
||||
import 'package:sync_relay_core/core.dart' show UpdateManifest, isDevBuild;
|
||||
|
||||
import '../../update/update_controller.dart';
|
||||
import '../../update/whats_new.dart';
|
||||
import '../../widgets/update_banner.dart';
|
||||
import '../../widgets/whats_new_card.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
/// The band across the top of the window while an update is up for
|
||||
/// grabs: "Starsync 1.2.1 is ready", its notes, Update and restart and
|
||||
/// Later (gone until the next check or start). The shell keeps it off
|
||||
/// grabs: "Starsync 1.2.1 is ready", its first changes (or its one-line
|
||||
/// notes), What's new (the whole list, [showWhatsNew]), Update and restart
|
||||
/// and Later (gone until the next check or start). The shell keeps it off
|
||||
/// the full player while a video is on ([AppShell]).
|
||||
class UpdateBar extends StatelessWidget {
|
||||
final UpdateController? controller;
|
||||
@@ -31,6 +34,8 @@ class UpdateBar extends StatelessWidget {
|
||||
final behind = isDevBuild(current) ? null : 'You are running $current';
|
||||
final String title;
|
||||
String detail;
|
||||
// The offered version's first changes instead of its notes.
|
||||
var preview = false;
|
||||
switch (s.phase) {
|
||||
case UpdatePhase.downloading:
|
||||
title = 'Downloading $appName ${m.version}… ${((s.progress ?? 0) * 100).round()}%';
|
||||
@@ -51,9 +56,11 @@ class UpdateBar extends StatelessWidget {
|
||||
} else if (m.isRequired) {
|
||||
title = '$appName ${m.version} is required to join servers on it';
|
||||
detail = m.notes;
|
||||
preview = m.changes != null;
|
||||
} else {
|
||||
title = '$appName ${m.version} is ready';
|
||||
detail = m.notes;
|
||||
preview = m.changes != null;
|
||||
}
|
||||
detail = [?behind, detail].where((t) => t.isNotEmpty).join(' · ');
|
||||
}
|
||||
@@ -88,7 +95,9 @@ class UpdateBar extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fg, fontWeight: FontWeight.w600)),
|
||||
if (detail.isNotEmpty)
|
||||
if (preview)
|
||||
WhatsNewPreview(changes: m.changes!, color: fg, lead: behind)
|
||||
else if (detail.isNotEmpty)
|
||||
Tooltip(
|
||||
message: detail,
|
||||
waitDuration: const Duration(milliseconds: 600),
|
||||
@@ -102,6 +111,14 @@ class UpdateBar extends StatelessWidget {
|
||||
),
|
||||
if (!busy) ...[
|
||||
const SizedBox(width: 12),
|
||||
if (m.changes != null || m.notes.trim().isNotEmpty)
|
||||
TextButton.icon(
|
||||
key: const ValueKey('update-whats-new'),
|
||||
style: TextButton.styleFrom(foregroundColor: fg),
|
||||
onPressed: () => _openWhatsNew(context, c, m, s.canInstall),
|
||||
icon: const Icon(Icons.auto_awesome, size: 16),
|
||||
label: const Text("What's new"),
|
||||
),
|
||||
if (!m.isRequired)
|
||||
TextButton(
|
||||
key: const ValueKey('update-later'),
|
||||
@@ -127,6 +144,27 @@ class UpdateBar extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The offered version's card, with Update and restart when it installs here.
|
||||
void _openWhatsNew(BuildContext context, UpdateController c, UpdateManifest m, bool canInstall) {
|
||||
showWhatsNew(
|
||||
context,
|
||||
ReleaseNotes.of(m),
|
||||
overline: canInstall ? '$appName · ready to install' : '$appName · new release',
|
||||
action: canInstall
|
||||
? Builder(
|
||||
builder: (context) => FilledButton(
|
||||
key: const ValueKey('whats-new-install'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).maybePop();
|
||||
c.install();
|
||||
},
|
||||
child: const Text('Update and restart'),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// The existing update card in a dialog; closes itself when the card
|
||||
/// would render nothing (e.g. "Later").
|
||||
Future<void> showUpdateDialog(BuildContext context, {UpdateController? controller}) {
|
||||
|
||||
@@ -17,11 +17,14 @@ import '../session/app_events.dart' show ActionFailed, ScheduleResult;
|
||||
import '../screens/home/health_probe.dart';
|
||||
import '../screens/home/media_picker.dart';
|
||||
import '../session/identity.dart';
|
||||
import '../screens/home/ui.dart' show appName;
|
||||
import '../screens/home/update_indicator.dart';
|
||||
import '../app/app_graph.dart';
|
||||
import '../session/navigation.dart' show Place;
|
||||
import '../state/settings.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import '../update/whats_new.dart';
|
||||
import '../widgets/whats_new_card.dart';
|
||||
import 'chat_panel.dart';
|
||||
import 'connect_cards.dart';
|
||||
import 'handoff.dart';
|
||||
@@ -52,6 +55,9 @@ import 'welcome.dart';
|
||||
class AppShell extends StatefulWidget {
|
||||
final UpdateController? updates;
|
||||
final bool autoUpdateCheck;
|
||||
/// The card once after an update; defaults to [WhatsNew.instance] when
|
||||
/// [autoUpdateCheck] is on (off in tests unless given).
|
||||
final WhatsNew? whatsNew;
|
||||
/// Off in tests: the drag area, window buttons and fullscreen need
|
||||
/// window_manager.
|
||||
final bool windowControls;
|
||||
@@ -67,6 +73,7 @@ class AppShell extends StatefulWidget {
|
||||
super.key,
|
||||
this.updates,
|
||||
this.autoUpdateCheck = true,
|
||||
this.whatsNew,
|
||||
this.windowControls = true,
|
||||
this.healthGet = defaultHealthGet,
|
||||
this.jellyfin,
|
||||
@@ -158,6 +165,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
// Reads linked servers and checks their tokens in the background.
|
||||
_jellyfin.ensureLoaded();
|
||||
if (widget.autoUpdateCheck) _autoCheck();
|
||||
if (widget.whatsNew != null || widget.autoUpdateCheck) unawaited(_startWhatsNew());
|
||||
HardwareKeyboard.instance.addHandler(_onKey);
|
||||
final nav = _graph.navigation;
|
||||
final coord = _graph.coordinator;
|
||||
@@ -241,6 +249,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
stop();
|
||||
}
|
||||
HardwareKeyboard.instance.removeHandler(_onKey);
|
||||
_whatsNewSource?.due.removeListener(_maybeWhatsNew);
|
||||
_updateTimer?.cancel();
|
||||
_fade.dispose();
|
||||
_online.dispose();
|
||||
@@ -276,6 +285,36 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
|
||||
}
|
||||
|
||||
WhatsNew? _whatsNewSource;
|
||||
bool _whatsNewOpen = false;
|
||||
|
||||
/// What's new once after an update: due now (notes kept by the install)
|
||||
/// or once a check reads this version's manifest.
|
||||
Future<void> _startWhatsNew() async {
|
||||
await AppSettings.instance.ensureLoaded();
|
||||
if (!mounted) return;
|
||||
final w = _whatsNewSource = widget.whatsNew ?? WhatsNew.instance;
|
||||
w.due.addListener(_maybeWhatsNew);
|
||||
// Not over the full player while a video is on: once it ends or minimises.
|
||||
_stops.add(effect(() {
|
||||
_watching.value;
|
||||
untracked(() => _later(_maybeWhatsNew));
|
||||
}));
|
||||
w.start(widget.updates ?? UpdateController.instance);
|
||||
_maybeWhatsNew();
|
||||
}
|
||||
|
||||
void _maybeWhatsNew() {
|
||||
final w = _whatsNewSource;
|
||||
final notes = w?.due.value;
|
||||
if (w == null || notes == null || !mounted || _whatsNewOpen) return;
|
||||
if (_shown == PlayerMode.full && _watching.peek()) return;
|
||||
_whatsNewOpen = true;
|
||||
w.shown();
|
||||
unawaited(showWhatsNew(context, notes, overline: '$appName · updated')
|
||||
.whenComplete(() => _whatsNewOpen = false));
|
||||
}
|
||||
|
||||
void _wentOnline() {
|
||||
_unread = 0;
|
||||
_feedSeen = _graph.coordinator.feedLog.peek().length;
|
||||
@@ -313,6 +352,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
}
|
||||
if (!mounted || seq != _modeSeq) return;
|
||||
setState(() => _shown = m);
|
||||
if (m != PlayerMode.full) _later(_maybeWhatsNew);
|
||||
await _fade.animateTo(1, duration: fadeThroughTime * (1 - fadeOutPart), curve: ease);
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ const keyAutoSitOut = 'autoSitOut';
|
||||
const _keyChatPopups = 'chatPopups';
|
||||
const _keyNotifyPeople = 'notifyPeople';
|
||||
const _keyShareDiagnostics = 'shareDiagnostics';
|
||||
const _keyWhatsNewSeen = 'whatsNewSeen';
|
||||
const _keyWhatsNewNotes = 'whatsNewNotes';
|
||||
|
||||
enum HwdecMode { auto, off, custom }
|
||||
|
||||
@@ -107,6 +109,13 @@ class AppSettings {
|
||||
/// `SfxKind` name -> `SoundPreset` name, per-sound overrides.
|
||||
final soundPresetFor = signal<Map<String, String>>(const {});
|
||||
|
||||
/// What's new (`update/whats_new.dart`): the version whose card was
|
||||
/// shown, and the notes last kept (a version's `ReleaseNotes` as JSON).
|
||||
String? whatsNewSeen;
|
||||
Map<String, Object?>? whatsNewNotes;
|
||||
/// The box was empty when this run opened it: a new install.
|
||||
bool freshInstall = false;
|
||||
|
||||
/// Video shaders: one choice, used on join and changed both here and
|
||||
/// in the session (Ctrl+0..6, the shader button).
|
||||
final shaderPreset = signal(ShaderPreset.off);
|
||||
@@ -131,6 +140,7 @@ class AppSettings {
|
||||
Future<void> _loadNow() async {
|
||||
try {
|
||||
final box = _box = await Hive.openBox(settingsBoxName);
|
||||
freshInstall = box.isEmpty;
|
||||
for (final line in await migrateSettings(box)) {
|
||||
debugPrint(line);
|
||||
}
|
||||
@@ -194,6 +204,10 @@ class AppSettings {
|
||||
shaderQuality.value =
|
||||
ShaderQuality.values.where((q) => q.name == quality).firstOrNull ??
|
||||
ShaderQuality.hq;
|
||||
final seen = box.get(_keyWhatsNewSeen);
|
||||
if (seen is String) whatsNewSeen = seen;
|
||||
final notes = box.get(_keyWhatsNewNotes);
|
||||
if (notes is Map) whatsNewNotes = {for (final e in notes.entries) '${e.key}': e.value};
|
||||
final files = box.get(_keyCustomShaders);
|
||||
if (files is List) {
|
||||
customShaders.value = List.unmodifiable(files.whereType<String>());
|
||||
@@ -328,6 +342,16 @@ class AppSettings {
|
||||
_put(_keyCustomShaders, [...paths]);
|
||||
}
|
||||
|
||||
Future<void> setWhatsNewSeen(String version) {
|
||||
whatsNewSeen = version;
|
||||
return _put(_keyWhatsNewSeen, version);
|
||||
}
|
||||
|
||||
Future<void> setWhatsNewNotes(Map<String, Object?> notes) {
|
||||
whatsNewNotes = notes;
|
||||
return _put(_keyWhatsNewNotes, notes);
|
||||
}
|
||||
|
||||
Future<void> _put(String key, Object value) async {
|
||||
try {
|
||||
final box = _box ??
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:hive_ce_flutter/hive_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../platform/app_shutdown.dart';
|
||||
import 'whats_new.dart';
|
||||
|
||||
// In-app update flow behind the update bar (UpdateBar). Same code path as
|
||||
// `--upgrade` (core's updater, downloadAndApply); this adds state for the
|
||||
@@ -58,6 +59,9 @@ class UpdateController {
|
||||
final Directory? installRoot;
|
||||
final RestartFn restart;
|
||||
final bool skipDevBuilds;
|
||||
/// Awaited after the apply, before [restart]: keeps the new version's
|
||||
/// notes for its first start ([WhatsNew.installing]).
|
||||
final Future<void> Function(UpdateManifest manifest)? beforeRestart;
|
||||
|
||||
UpdateController(
|
||||
{this.currentVersion = appVersion,
|
||||
@@ -65,9 +69,14 @@ class UpdateController {
|
||||
this.executablePath,
|
||||
this.installRoot,
|
||||
this.restart = _closeAndRestart,
|
||||
this.skipDevBuilds = true});
|
||||
this.skipDevBuilds = true,
|
||||
Future<void> Function(UpdateManifest manifest)? beforeRestart})
|
||||
: beforeRestart = beforeRestart ?? WhatsNew.instance.installing;
|
||||
|
||||
final status = ValueNotifier(const UpdateStatus(UpdatePhase.idle));
|
||||
/// The manifest of the last check that read one, newer or not (What's
|
||||
/// new picks up this version's notes from it).
|
||||
final latest = ValueNotifier<UpdateManifest?>(null);
|
||||
/// Outcome of the last finished check, for Settings/About wording
|
||||
/// ("Up to date", "Check failed: ..."); null until one finishes.
|
||||
final lastCheck = ValueNotifier<String?>(null);
|
||||
@@ -108,6 +117,7 @@ class UpdateController {
|
||||
try {
|
||||
final m = await fetchLatestManifest(
|
||||
currentVersion: currentVersion, url: manifestUrl);
|
||||
latest.value = m;
|
||||
if (!isNewerVersion(currentVersion, m.version)) {
|
||||
_log.info('up to date ($currentVersion, latest ${m.version})');
|
||||
lastCheck.value = 'Up to date';
|
||||
@@ -173,6 +183,11 @@ class UpdateController {
|
||||
_log.info('updated to ${m.version}, restarting');
|
||||
status.value =
|
||||
UpdateStatus(UpdatePhase.restarting, manifest: m, canInstall: true);
|
||||
try {
|
||||
await beforeRestart?.call(m);
|
||||
} catch (e) {
|
||||
_log.warning('keeping the notes of ${m.version}: $e');
|
||||
}
|
||||
// Started by an old name (1.2.x's restart, an old launcher): the new
|
||||
// binary next to it.
|
||||
await restart(relaunchExecutable(_exe), launchArgs);
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../state/settings.dart';
|
||||
import 'update_controller.dart';
|
||||
|
||||
// "What's new in X.Y.Z" once after an update. The notes come from the
|
||||
// manifest: the one installed (kept before the restart) or, for an update
|
||||
// made another way (1.3.x's updater, `--upgrade`, by hand), the manifest of
|
||||
// the first check that finds this very version. A fresh install shows
|
||||
// nothing; Settings → About opens the card again.
|
||||
|
||||
/// What the card shows for one version.
|
||||
class ReleaseNotes {
|
||||
final String version;
|
||||
|
||||
/// The one-line notes (all a manifest from before the changelog has).
|
||||
final String notes;
|
||||
final ReleaseChanges? changes;
|
||||
const ReleaseNotes({required this.version, this.notes = '', this.changes});
|
||||
|
||||
factory ReleaseNotes.of(UpdateManifest m) => ReleaseNotes(version: m.version, notes: m.notes, changes: m.changes);
|
||||
|
||||
/// Null for anything unreadable.
|
||||
static ReleaseNotes? fromJson(Object? json) {
|
||||
if (json is! Map) return null;
|
||||
final version = json['version'];
|
||||
if (version is! String || version.isEmpty) return null;
|
||||
final notes = json['notes'];
|
||||
return ReleaseNotes(
|
||||
version: version,
|
||||
notes: notes is String ? notes : '',
|
||||
changes: ReleaseChanges.fromJson(json['changes']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'version': version,
|
||||
'notes': notes,
|
||||
if (changes != null) 'changes': changes!.toJson(),
|
||||
};
|
||||
|
||||
bool get isEmpty => notes.trim().isEmpty && (changes?.isEmpty ?? true);
|
||||
}
|
||||
|
||||
/// Where [WhatsNew] keeps its two values (settings box; tests: memory).
|
||||
abstract class WhatsNewStore {
|
||||
/// The version whose card was shown (or that was installed fresh).
|
||||
String? get seen;
|
||||
Future<void> setSeen(String version);
|
||||
|
||||
/// The notes last known for an installed or current version.
|
||||
ReleaseNotes? get notes;
|
||||
Future<void> setNotes(ReleaseNotes notes);
|
||||
|
||||
/// The settings box was empty at start: a fresh install.
|
||||
bool get fresh;
|
||||
}
|
||||
|
||||
class SettingsWhatsNewStore implements WhatsNewStore {
|
||||
final AppSettings settings;
|
||||
SettingsWhatsNewStore([AppSettings? settings]) : settings = settings ?? AppSettings.instance;
|
||||
@override
|
||||
String? get seen => settings.whatsNewSeen;
|
||||
@override
|
||||
Future<void> setSeen(String version) => settings.setWhatsNewSeen(version);
|
||||
@override
|
||||
ReleaseNotes? get notes => ReleaseNotes.fromJson(settings.whatsNewNotes);
|
||||
@override
|
||||
Future<void> setNotes(ReleaseNotes notes) => settings.setWhatsNewNotes(notes.toJson());
|
||||
@override
|
||||
bool get fresh => settings.freshInstall;
|
||||
}
|
||||
|
||||
class MemoryWhatsNewStore implements WhatsNewStore {
|
||||
@override
|
||||
String? seen;
|
||||
@override
|
||||
ReleaseNotes? notes;
|
||||
@override
|
||||
bool fresh;
|
||||
MemoryWhatsNewStore({this.seen, this.notes, this.fresh = false});
|
||||
@override
|
||||
Future<void> setSeen(String version) async => seen = version;
|
||||
@override
|
||||
Future<void> setNotes(ReleaseNotes notes) async => this.notes = notes;
|
||||
}
|
||||
|
||||
class WhatsNew {
|
||||
static final instance = WhatsNew();
|
||||
|
||||
final String currentVersion;
|
||||
|
||||
final WhatsNewStore store;
|
||||
|
||||
WhatsNew({this.currentVersion = appVersion, WhatsNewStore? store}) : store = store ?? SettingsWhatsNewStore();
|
||||
|
||||
/// The card to show now (once); null when there is none.
|
||||
final due = ValueNotifier<ReleaseNotes?>(null);
|
||||
UpdateController? _updates;
|
||||
|
||||
/// At start, settings loaded: a pending card for this version, and
|
||||
/// [updates]' checks from now on.
|
||||
void start(UpdateController updates) {
|
||||
if (!identical(_updates, updates)) {
|
||||
_updates?.latest.removeListener(_onManifest);
|
||||
_updates = updates..latest.addListener(_onManifest);
|
||||
}
|
||||
final s = store;
|
||||
if (s.seen == currentVersion) return;
|
||||
if (s.seen == null && s.fresh) {
|
||||
// A new install: nothing changed for this person.
|
||||
unawaited(s.setSeen(currentVersion));
|
||||
return;
|
||||
}
|
||||
final known = s.notes;
|
||||
if (known != null && known.version == currentVersion && !known.isEmpty) due.value = known;
|
||||
}
|
||||
|
||||
void _onManifest() {
|
||||
final m = _updates?.latest.value;
|
||||
if (m != null) offer(ReleaseNotes.of(m));
|
||||
}
|
||||
|
||||
/// Notes for some version (an update check's manifest). Kept when they
|
||||
/// are this version's; due when its card was not shown yet.
|
||||
void offer(ReleaseNotes n) {
|
||||
if (n.version != currentVersion || n.isEmpty) return;
|
||||
final s = store;
|
||||
unawaited(s.setNotes(n));
|
||||
if (s.seen != currentVersion && !s.fresh) due.value = n;
|
||||
}
|
||||
|
||||
/// Before the restart into [m]: its notes for the first start.
|
||||
Future<void> installing(UpdateManifest m) => store.setNotes(ReleaseNotes.of(m));
|
||||
|
||||
/// The card was shown: never again for this version.
|
||||
void shown() {
|
||||
due.value = null;
|
||||
unawaited(store.setSeen(currentVersion));
|
||||
}
|
||||
|
||||
/// This version's notes, when known (Settings → About).
|
||||
ReleaseNotes? get current {
|
||||
final n = store.notes;
|
||||
return n != null && n.version == currentVersion && !n.isEmpty ? n : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/ui.dart' show appName;
|
||||
import '../shell/motion.dart';
|
||||
import '../update/whats_new.dart';
|
||||
import 'app_menu.dart';
|
||||
|
||||
// The "What's new" card: a version's changes (CHANGELOG.md, through the
|
||||
// manifest's `changes`) as Added / Changed / Fixed sections, each with its
|
||||
// icon and colour, on the app's glass. The update band opens it for the
|
||||
// offered version, the first start after an update for the new one
|
||||
// ([WhatsNew]), Settings → About for the running one. Notes without
|
||||
// `changes` (older manifests) show as one paragraph.
|
||||
|
||||
enum ChangeKind {
|
||||
added('Added', Icons.auto_awesome_rounded, Icons.add_rounded, Color(0xFF7EE0A6)),
|
||||
changed('Changed', Icons.tune_rounded, Icons.arrow_forward_rounded, Color(0xFF8DB8FF)),
|
||||
fixed('Fixed', Icons.handyman_rounded, Icons.check_rounded, Color(0xFFFFC772));
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
/// Before each item.
|
||||
final IconData bullet;
|
||||
|
||||
/// Reads on the dark glass.
|
||||
final Color color;
|
||||
const ChangeKind(this.label, this.icon, this.bullet, this.color);
|
||||
|
||||
List<String> of(ReleaseChanges c) => switch (this) {
|
||||
ChangeKind.added => c.added,
|
||||
ChangeKind.changed => c.changed,
|
||||
ChangeKind.fixed => c.fixed,
|
||||
};
|
||||
}
|
||||
|
||||
/// The first [n] items, Added before Changed before Fixed.
|
||||
List<(ChangeKind, String)> topChanges(ReleaseChanges c, int n) => [
|
||||
for (final k in ChangeKind.values)
|
||||
for (final item in k.of(c)) (k, item),
|
||||
].take(n).toList();
|
||||
|
||||
/// Opens the card over the window. [action]: a button beside Close (the
|
||||
/// band's "Update and restart").
|
||||
Future<void> showWhatsNew(BuildContext context, ReleaseNotes notes, {String? overline, Widget? action}) =>
|
||||
showPopover<void>(
|
||||
context,
|
||||
at: Alignment.center,
|
||||
dim: true,
|
||||
child: WhatsNewCard(notes: notes, overline: overline, action: action),
|
||||
);
|
||||
|
||||
class WhatsNewCard extends StatefulWidget {
|
||||
final ReleaseNotes notes;
|
||||
|
||||
/// The small line over the title (default: the app's name).
|
||||
final String? overline;
|
||||
final Widget? action;
|
||||
|
||||
/// Default: closes the route.
|
||||
final VoidCallback? onClose;
|
||||
const WhatsNewCard({super.key, required this.notes, this.overline, this.action, this.onClose});
|
||||
|
||||
@override
|
||||
State<WhatsNewCard> createState() => _WhatsNewCardState();
|
||||
}
|
||||
|
||||
class _WhatsNewCardState extends State<WhatsNewCard> with SingleTickerProviderStateMixin {
|
||||
late final _intro = AnimationController(
|
||||
vsync: this,
|
||||
duration: easeTime + Duration(milliseconds: 45 * math.min(_rows, 12)),
|
||||
)..forward();
|
||||
|
||||
int get _rows => widget.notes.changes?.length ?? 1;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_intro.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Row [i] of [_rows] slides up and fades in a little after the one above.
|
||||
Widget _staggered(int i, Widget child) {
|
||||
final start = math.min(i, 12) * 0.06;
|
||||
final a = CurvedAnimation(
|
||||
parent: _intro,
|
||||
curve: Interval(math.min(start, 0.6), 1, curve: ease),
|
||||
);
|
||||
return FadeTransition(
|
||||
opacity: a,
|
||||
child: SlideTransition(
|
||||
position: Tween(begin: const Offset(0, 0.25), end: Offset.zero).animate(a),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _close() => widget.onClose != null ? widget.onClose!() : Navigator.of(context).maybePop();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
const muted = Color(0xB3FFFFFF);
|
||||
final n = widget.notes;
|
||||
final changes = n.changes;
|
||||
final screen = MediaQuery.sizeOf(context);
|
||||
final maxHeight = math.min(620.0, screen.height - 64);
|
||||
var row = 0;
|
||||
final body = <Widget>[
|
||||
if (changes == null)
|
||||
_staggered(row++, Text(n.notes, style: const TextStyle(fontSize: 13.5, height: 1.45)))
|
||||
else
|
||||
for (final k in ChangeKind.values)
|
||||
if (k.of(changes).isNotEmpty) ...[
|
||||
_staggered(row++, _SectionHead(kind: k, count: k.of(changes).length)),
|
||||
const SizedBox(height: 6),
|
||||
for (final item in k.of(changes)) _staggered(row++, _Item(kind: k, text: item)),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
];
|
||||
return AppGlass(
|
||||
key: const ValueKey('whats-new-card'),
|
||||
radius: 16,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints.tightFor(width: math.min(460.0, screen.width - 32)).copyWith(maxHeight: maxHeight),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 8, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [scheme.primary, scheme.tertiary.withValues(alpha: 0.85)],
|
||||
),
|
||||
boxShadow: [BoxShadow(color: scheme.primary.withValues(alpha: 0.35), blurRadius: 14)],
|
||||
),
|
||||
child: const SizedBox.square(
|
||||
dimension: 40,
|
||||
child: Icon(Icons.auto_awesome, color: Colors.white, size: 22),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
(widget.overline ?? appName).toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: scheme.primary,
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
"What's new in ${n.version}",
|
||||
key: const ValueKey('whats-new-title'),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, height: 1.2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Close',
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.close, size: 18, color: muted),
|
||||
onPressed: _close,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (changes != null && n.notes.trim().isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 10, 18, 0),
|
||||
child: Text(n.notes, style: const TextStyle(color: muted, fontSize: 13, height: 1.4)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(height: 1, thickness: 0.5, color: AppMenuStyle.border),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
key: const ValueKey('whats-new-list'),
|
||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 4),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: body),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, thickness: 0.5, color: AppMenuStyle.border),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (widget.action != null) ...[
|
||||
TextButton(key: const ValueKey('whats-new-close'), onPressed: _close, child: const Text('Close')),
|
||||
const SizedBox(width: 8),
|
||||
widget.action!,
|
||||
] else
|
||||
FilledButton.tonal(
|
||||
key: const ValueKey('whats-new-close'),
|
||||
onPressed: _close,
|
||||
child: const Text('Nice'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHead extends StatelessWidget {
|
||||
final ChangeKind kind;
|
||||
final int count;
|
||||
const _SectionHead({required this.kind, required this.count});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: DecoratedBox(
|
||||
key: ValueKey('whats-new-${kind.name}'),
|
||||
decoration: BoxDecoration(
|
||||
color: kind.color.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: kind.color.withValues(alpha: 0.28), width: 0.5),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 3, 10, 3),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(kind.icon, size: 14, color: kind.color),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
kind.label,
|
||||
style: TextStyle(color: kind.color, fontSize: 12, fontWeight: FontWeight.w700, letterSpacing: 0.3),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text('$count', style: TextStyle(color: kind.color.withValues(alpha: 0.7), fontSize: 11.5)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _Item extends StatelessWidget {
|
||||
final ChangeKind kind;
|
||||
final String text;
|
||||
const _Item({required this.kind, required this.text});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2, left: 2),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(color: kind.color.withValues(alpha: 0.16), shape: BoxShape.circle),
|
||||
child: SizedBox.square(
|
||||
dimension: 16,
|
||||
child: Icon(
|
||||
kind.bullet,
|
||||
size: 12,
|
||||
color: kind.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(text, style: const TextStyle(fontSize: 13.5, height: 1.4))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The band's line: the first items with their kind's icon, in [color].
|
||||
class WhatsNewPreview extends StatelessWidget {
|
||||
final ReleaseChanges changes;
|
||||
final Color color;
|
||||
final String? lead;
|
||||
const WhatsNewPreview({super.key, required this.changes, required this.color, this.lead});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final top = topChanges(changes, 3);
|
||||
final style = TextStyle(color: color.withValues(alpha: 0.85), fontSize: 12);
|
||||
return Text.rich(
|
||||
key: const ValueKey('whats-new-preview'),
|
||||
TextSpan(
|
||||
children: [
|
||||
if (lead != null) TextSpan(text: '$lead · '),
|
||||
for (final (i, (k, item)) in top.indexed) ...[
|
||||
if (i > 0) const TextSpan(text: ' '),
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Icon(k.icon, size: 13, color: color.withValues(alpha: 0.85)),
|
||||
),
|
||||
),
|
||||
TextSpan(text: item),
|
||||
],
|
||||
],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: style,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user