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:
foxyzandClaude Opus 5.5 committed 2026-10-18 02:11:37 +03:00
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}) {
+40
View File
@@ -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);
}
+24
View File
@@ -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);
+150
View File
@@ -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,
);
}
}