From 5436bb263093fff13964a8e0d404a1961a75c4a9 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Thu, 17 Sep 2026 19:37:24 +1000 Subject: [PATCH 1/2] Add "NEW" badges for recently-shipped filters and parameters Adds an optional sinceAppVersion field to FilterSchema and ParameterDefinition, and a WhatsNewService that badges anything tagged with it whenever the tag is newer than the version the user last updated from. Badges persist across every launch of the current app version and only advance the next time the app itself updates, tracked via a separate "last run version" marker distinct from the comparison baseline. Rendered via a shared NewBadge widget in both the pass list (filter level) and the dynamic parameter widgets (property level). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014GLXdGLfPwgYjW1AkonGqN --- CLAUDE.md | 8 ++ app/lib/main.dart | 6 + app/lib/models/filter_schema.dart | 18 ++- app/lib/services/whats_new_service.dart | 109 ++++++++++++++ app/lib/views/pass_list/pass_list_item.dart | 10 ++ app/lib/views/pass_list/pass_list_panel.dart | 5 + .../pass_settings/pass_settings_inline.dart | 9 +- .../settings/widgets/parameter_widgets.dart | 46 +++++- app/lib/widgets/new_badge.dart | 32 +++++ app/test/filter_schema_curation_test.dart | 37 +++++ app/test/whats_new_service_test.dart | 133 ++++++++++++++++++ docs/FILTER_SCHEMA.md | 50 ++++++- 12 files changed, 451 insertions(+), 12 deletions(-) create mode 100644 app/lib/services/whats_new_service.dart create mode 100644 app/lib/widgets/new_badge.dart create mode 100644 app/test/whats_new_service_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index 57da8f0..207fded 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -562,6 +562,14 @@ or `UiSection` directly (both silently drop it if written one level up). `visibleWhen` outside `ui`, and every condition must name a parameter/method the filter actually has. +**`sinceAppVersion`** (filter-level or per-parameter, e.g. `"1.2.0"`): tags +when a filter or parameter shipped, purely so `WhatsNewService` can draw a +"NEW" badge next to it — persisting across every launch of the current app +version, and only advancing on the next real update, not clearing after one +run. Stamp it when you ship the feature, not retroactively on anything already +out — see "NEW badges" in +[docs/FILTER_SCHEMA.md](docs/FILTER_SCHEMA.md). + Several ongoing rules for schemas and the panel that renders them, each guarded by `filter_schema_curation_test.dart` unless noted — see docs/ENGINEERING_NOTES.md for the audits that found violations of each: diff --git a/app/lib/main.dart b/app/lib/main.dart index b3ad9ce..e9b7982 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -12,6 +12,7 @@ import 'services/preset_service.dart'; import 'services/temp_directory_service.dart'; import 'services/tool_locator.dart'; import 'services/update_checker.dart'; +import 'services/whats_new_service.dart'; import 'viewmodels/main_viewmodel.dart'; import 'views/dependency_download_dialog.dart'; import 'views/main_window.dart'; @@ -31,6 +32,11 @@ void main() async { // settings don't flash from simple to advanced on startup. await AdvancedModeService.instance.initialize(); + // Records the previously-installed version (before overwriting it with the + // current one) so "NEW" badges on filters/parameters can compare against + // it. Must happen before the first pass list or settings panel builds. + await WhatsNewService.instance.initialize(); + // Load the saved default for existing output files (issue #85) before the // first job can be started. await OverwriteBehaviorService.instance.initialize(); diff --git a/app/lib/models/filter_schema.dart b/app/lib/models/filter_schema.dart index 0cbe379..bc05cb9 100644 --- a/app/lib/models/filter_schema.dart +++ b/app/lib/models/filter_schema.dart @@ -126,6 +126,13 @@ class ParameterDefinition { /// When disabled (null value), the parameter is not passed to VapourSynth. final bool? optional; + /// The app version (e.g. `"1.2.0"`, matching `pubspec.yaml`) this parameter + /// was added or last meaningfully changed in. Purely cosmetic — it only + /// feeds [WhatsNewService.isNew] to decide whether a "NEW" badge shows next + /// to this control; nothing else reads it. Leave unset for anything that + /// isn't worth flagging (most edits aren't). + final String? sinceAppVersion; + const ParameterDefinition({ required this.type, required this.defaultValue, @@ -136,6 +143,7 @@ class ParameterDefinition { this.vapoursynth, this.ui, this.optional, + this.sinceAppVersion, }); /// Get the VapourSynth parameter name (falls back to schema name if not specified). @@ -396,9 +404,16 @@ class FilterSchema { /// Unique identifier for this filter. final String id; - /// Schema version. + /// Schema version — this JSON file's own revision, unrelated to the app. final String version; + /// The app version (e.g. `"1.2.0"`, matching `pubspec.yaml`) this filter was + /// introduced in. Not the same thing as [version] above: that tracks this + /// schema file, this tracks the shipping app. Feeds a "NEW" badge in the + /// pass list via [WhatsNewService.isNew] — leave unset for anything already + /// shipped before this mechanism existed. + final String? sinceAppVersion; + /// Display name. final String name; @@ -459,6 +474,7 @@ class FilterSchema { this.schema, required this.id, required this.version, + this.sinceAppVersion, required this.name, this.description, this.longDescription, diff --git a/app/lib/services/whats_new_service.dart b/app/lib/services/whats_new_service.dart new file mode 100644 index 0000000..7c79398 --- /dev/null +++ b/app/lib/services/whats_new_service.dart @@ -0,0 +1,109 @@ +import 'package:flutter/foundation.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Decides whether a schema element tagged with `sinceAppVersion` (on +/// [FilterSchema] or [ParameterDefinition]) should show a "NEW" badge. +/// +/// The rule: something is new if it was added in a version released *after* +/// the last version the user actually updated **from** — and it stays +/// flagged as new across every launch of the current version, only moving +/// forward the next time the app itself updates. That needs two separate +/// pieces of stored state, not one: +/// +/// - `lastRunAppVersion` — whatever version ran last launch, checked on +/// every launch purely to detect the *moment* an update happened. +/// - `lastSeenAppVersion` — the actual comparison baseline used by [isNew]. +/// It only advances at that moment, to the version being left behind, and +/// is otherwise left untouched — which is what makes badges survive many +/// launches of the same version instead of clearing after one. +/// +/// A fresh install (or an upgrade from a build that predates this tracking, +/// which looks the same — no `lastRunAppVersion` stored yet) flags nothing as +/// new: both markers are seeded to the current version, so there's no earlier +/// baseline to diff against yet. +class WhatsNewService { + static final WhatsNewService instance = WhatsNewService._(); + WhatsNewService._(); + + static const String _baselineKey = 'lastSeenAppVersion'; + static const String _lastRunVersionKey = 'lastRunAppVersion'; + + /// The comparison baseline for this session — the version the user last + /// updated from, held steady until the next real update. Null when there's + /// nothing to compare against yet. + String? _lastSeenVersion; + bool _loaded = false; + + /// Detect whether the app itself changed version since the last launch, + /// and advance the badge baseline exactly then. Safe to call again; only + /// the first call touches storage. + Future initialize() async { + if (_loaded) return; + try { + final packageInfo = await PackageInfo.fromPlatform(); + final currentVersion = packageInfo.version; + final prefs = await SharedPreferences.getInstance(); + + final lastRunVersion = prefs.getString(_lastRunVersionKey); + final storedBaseline = prefs.getString(_baselineKey); + + if (lastRunVersion == null) { + // Nothing to diff against yet — seed both markers to now. + _lastSeenVersion = currentVersion; + await prefs.setString(_baselineKey, currentVersion); + } else if (lastRunVersion != currentVersion) { + // The version actually changed since the last launch: this is the + // one moment the baseline moves, to whatever was running just + // before — so anything newer than that keeps showing as new on + // every launch of the new version, until this happens again. + _lastSeenVersion = lastRunVersion; + await prefs.setString(_baselineKey, lastRunVersion); + } else { + // Same version as last launch — leave the baseline alone so badges + // persist instead of clearing after a single run. + _lastSeenVersion = storedBaseline; + } + + await prefs.setString(_lastRunVersionKey, currentVersion); + } catch (_) { + // Version info or storage being unavailable shouldn't stop the app + // starting — just don't badge anything this session. + _lastSeenVersion = null; + } + _loaded = true; + } + + /// Whether a property/filter tagged `sinceAppVersion: since` should show a + /// "NEW" badge right now. False for anything untagged, and false when + /// there's no baseline yet to compare against. + bool isNew(String? since) { + if (since == null || _lastSeenVersion == null) return false; + return _compareVersions(since, _lastSeenVersion!) > 0; + } + + /// Reset to the unloaded default. Tests only — the singleton outlives a + /// single test case otherwise. + @visibleForTesting + void resetForTesting() { + _lastSeenVersion = null; + _loaded = false; + } +} + +/// Compare two dotted version strings numerically ("1.10.0" > "1.9.0"). +/// Returns positive if [a] > [b], negative if [a] < [b], zero if equal. +/// Non-numeric or differing-length components degrade to a component-wise +/// best effort rather than throwing. +int _compareVersions(String a, String b) { + final partsA = a.split('.').map((p) => int.tryParse(p.trim()) ?? 0).toList(); + final partsB = b.split('.').map((p) => int.tryParse(p.trim()) ?? 0).toList(); + + final length = partsA.length > partsB.length ? partsA.length : partsB.length; + for (var i = 0; i < length; i++) { + final na = i < partsA.length ? partsA[i] : 0; + final nb = i < partsB.length ? partsB[i] : 0; + if (na != nb) return na < nb ? -1 : 1; + } + return 0; +} diff --git a/app/lib/views/pass_list/pass_list_item.dart b/app/lib/views/pass_list/pass_list_item.dart index d69db19..1c039ea 100644 --- a/app/lib/views/pass_list/pass_list_item.dart +++ b/app/lib/views/pass_list/pass_list_item.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../models/pass_relevance.dart'; import '../../models/processing_pipeline.dart'; +import '../../widgets/new_badge.dart'; /// A single item in the pass list showing a processing pass. /// @@ -22,6 +23,10 @@ class PassListItem extends StatelessWidget { /// detection is a hint and is sometimes wrong. final PassRelevanceResult relevance; + /// Whether the filter behind this pass is tagged with a `sinceAppVersion` + /// that [WhatsNewService.isNew] considers new for this session. + final bool isNew; + /// Settings shown inline while expanded. Only built for the expanded item. final Widget? expandedChild; @@ -35,6 +40,7 @@ class PassListItem extends StatelessWidget { required this.onToggle, required this.onTap, this.relevance = PassRelevanceResult.neutral, + this.isNew = false, this.expandedChild, }); @@ -141,6 +147,10 @@ class PassListItem extends StatelessWidget { ), ), ), + if (isNew) ...[ + const SizedBox(width: 8), + const NewBadge(), + ], if (relevance.isRecommended) ...[ const SizedBox(width: 8), _buildSuggestedBadge(context, colorScheme), diff --git a/app/lib/views/pass_list/pass_list_panel.dart b/app/lib/views/pass_list/pass_list_panel.dart index e9dee6f..dc4c843 100644 --- a/app/lib/views/pass_list/pass_list_panel.dart +++ b/app/lib/views/pass_list/pass_list_panel.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../models/filter_registry.dart'; import '../../models/pass_relevance.dart'; import '../../models/processing_pipeline.dart'; +import '../../services/whats_new_service.dart'; import '../../services/whisper_addon_manager.dart'; import '../../viewmodels/main_viewmodel.dart'; import '../pass_settings/pass_settings_inline.dart'; @@ -90,6 +92,8 @@ class PassListPanel extends StatelessWidget { ValueChanged? onToggle, }) { final isExpanded = viewModel.selectedPass == passType; + final schema = + FilterRegistry.instance.get(PassSettingsInline.filterIdFor(passType)); return PassListItem( passType: passType, title: title, @@ -97,6 +101,7 @@ class PassListPanel extends StatelessWidget { isEnabled: isEnabled, isExpanded: isExpanded, relevance: relevanceFor(passType, viewModel.videoInfo), + isNew: WhatsNewService.instance.isNew(schema?.sinceAppVersion), onToggle: onToggle ?? (enabled) => viewModel.togglePass(passType, enabled), onTap: () => viewModel.selectPass(passType), expandedChild: isExpanded ? PassSettingsInline(passType: passType) : null, diff --git a/app/lib/views/pass_settings/pass_settings_inline.dart b/app/lib/views/pass_settings/pass_settings_inline.dart index 181ae3b..21b3ac6 100644 --- a/app/lib/views/pass_settings/pass_settings_inline.dart +++ b/app/lib/views/pass_settings/pass_settings_inline.dart @@ -24,8 +24,11 @@ class PassSettingsInline extends StatelessWidget { const PassSettingsInline({super.key, required this.passType}); - /// Maps PassType to filter schema ID. - static String _getFilterId(PassType passType) { + /// Maps PassType to filter schema ID. Public because `pass_list_panel.dart` + /// needs the same mapping to look up a pass's [FilterSchema] for its "NEW" + /// badge — kept in one place rather than a second switch to fall out of + /// sync with this one. + static String filterIdFor(PassType passType) { switch (passType) { case PassType.deinterlace: return 'deinterlace'; @@ -76,7 +79,7 @@ class PassSettingsInline extends StatelessWidget { Widget build(BuildContext context) { return Consumer( builder: (context, viewModel, child) { - final filterId = _getFilterId(passType); + final filterId = filterIdFor(passType); final schema = FilterRegistry.instance.get(filterId); // If schema not found, show a fallback message diff --git a/app/lib/views/settings/widgets/parameter_widgets.dart b/app/lib/views/settings/widgets/parameter_widgets.dart index 0614a10..7146d55 100644 --- a/app/lib/views/settings/widgets/parameter_widgets.dart +++ b/app/lib/views/settings/widgets/parameter_widgets.dart @@ -3,7 +3,32 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../../models/filter_schema.dart'; +import '../../../services/whats_new_service.dart'; import '../../../viewmodels/main_viewmodel.dart'; +import '../../../widgets/new_badge.dart'; + +/// A parameter's label, with a "NEW" badge appended when +/// [WhatsNewService.isNew] flags [param.sinceAppVersion]. Every widget below +/// renders its label through this instead of a bare `Text` so the badge shows +/// up consistently regardless of widget type. +Widget _paramLabel( + BuildContext context, + String label, + ParameterDefinition param, { + TextStyle? style, +}) { + final text = Text(label, style: style); + if (!WhatsNewService.instance.isNew(param.sinceAppVersion)) return text; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: text), + const SizedBox(width: 6), + const NewBadge(), + ], + ); +} /// Factory for creating parameter widgets based on schema definition. class ParameterWidgetFactory { @@ -236,8 +261,10 @@ class _SliderParameterWidget extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( + _paramLabel( + context, '$label: ${doubleValue.toStringAsFixed(precision)}', + param, style: Theme.of(context).textTheme.labelLarge, ), Slider( @@ -293,7 +320,8 @@ class _DropdownParameterWidget extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: Theme.of(context).textTheme.labelLarge), + _paramLabel(context, label, param, + style: Theme.of(context).textTheme.labelLarge), const SizedBox(height: 8), DropdownButtonFormField( value: matchedOption ?? options.firstOrNull, @@ -356,7 +384,8 @@ class _CheckboxParameterWidget extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: Theme.of(context).textTheme.labelLarge), + _paramLabel(context, label, param, + style: Theme.of(context).textTheme.labelLarge), const SizedBox(height: 8), DropdownButtonFormField( value: boolValue, @@ -390,7 +419,7 @@ class _CheckboxParameterWidget extends StatelessWidget { // Default: render as switch return SwitchListTile( - title: Text(label), + title: _paramLabel(context, label, param), subtitle: param.ui?.description != null ? Text(param.ui!.description!) : null, value: boolValue, contentPadding: EdgeInsets.zero, @@ -421,7 +450,8 @@ class _TextFieldParameterWidget extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: Theme.of(context).textTheme.labelLarge), + _paramLabel(context, label, param, + style: Theme.of(context).textTheme.labelLarge), const SizedBox(height: 8), TextFormField( initialValue: stringValue, @@ -515,7 +545,8 @@ class _FilePickerParameterWidgetState return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: Theme.of(context).textTheme.labelLarge), + _paramLabel(context, label, widget.param, + style: Theme.of(context).textTheme.labelLarge), const SizedBox(height: 8), Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -568,7 +599,8 @@ class _NumberParameterWidget extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: Theme.of(context).textTheme.labelLarge), + _paramLabel(context, label, param, + style: Theme.of(context).textTheme.labelLarge), const SizedBox(height: 8), TextFormField( initialValue: numValue.toString(), diff --git a/app/lib/widgets/new_badge.dart b/app/lib/widgets/new_badge.dart new file mode 100644 index 0000000..9a3817a --- /dev/null +++ b/app/lib/widgets/new_badge.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +/// A small "NEW" pill for a filter or parameter [WhatsNewService.isNew] +/// considers new for this session. +/// +/// Uses the tertiary color role rather than primary so it reads as a +/// distinct signal from the pass list's primary-colored "Suggested" badge — +/// two different things can be true of the same row. +class NewBadge extends StatelessWidget { + const NewBadge({super.key}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'NEW', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onTertiaryContainer, + fontWeight: FontWeight.w700, + fontSize: 10, + letterSpacing: 0.4, + ), + ), + ); + } +} diff --git a/app/test/filter_schema_curation_test.dart b/app/test/filter_schema_curation_test.dart index 7ec177f..ea85e4a 100644 --- a/app/test/filter_schema_curation_test.dart +++ b/app/test/filter_schema_curation_test.dart @@ -22,6 +22,10 @@ // or a `method` condition naming no method, can never be satisfied, so the // control it guards is invisible forever — the same silent failure in the // other direction. +// - `sinceAppVersion` (filter- or parameter-level) feeds a version compare +// that parses each dot-separated component as a plain integer, so a +// malformed value like "v1.2" or "1.2+3" doesn't error, it just compares +// wrong and mis-badges the "NEW" tag silently. // // Run with: flutter test test/filter_schema_curation_test.dart @@ -60,6 +64,39 @@ void main() { reason: 'a schema file exists but nothing loads it'); }); + group('sinceAppVersion is a comparable version string', () { + // WhatsNewService compares this against the app's own dotted-numeric + // version with plain integer parsing per component, not semver rules — a + // stray "v" prefix or a build suffix like "1.2+3" doesn't fail to parse, + // it silently compares as if a bare "1" (or "0"), badging the wrong things + // instead of erroring. Cheap to catch here since nothing else would. + final versionPattern = RegExp(r'^\d+\.\d+\.\d+$'); + + rawSchemas.forEach((filename, raw) { + final id = raw['id'] as String; + final parameters = (raw['parameters'] as Map).cast(); + + test('$id: filter-level sinceAppVersion, if set, looks like "1.2.0"', () { + final since = raw['sinceAppVersion']; + if (since == null) return; + expect(since, isA()); + expect(versionPattern.hasMatch(since as String), isTrue, + reason: '"$since" is not dotted-numeric X.Y.Z'); + }); + + test('$id: every parameter\'s sinceAppVersion, if set, looks like "1.2.0"', + () { + for (final entry in parameters.entries) { + final since = (entry.value as Map)['sinceAppVersion']; + if (since == null) continue; + expect(since, isA()); + expect(versionPattern.hasMatch(since as String), isTrue, + reason: '${entry.key}: "$since" is not dotted-numeric X.Y.Z'); + } + }); + }); + }); + group('method curation', () { for (final schema in schemas) { test('${schema.id}: first method is never advanced-only', () { diff --git a/app/test/whats_new_service_test.dart b/app/test/whats_new_service_test.dart new file mode 100644 index 0000000..cf32c6d --- /dev/null +++ b/app/test/whats_new_service_test.dart @@ -0,0 +1,133 @@ +// Tests for the "NEW" badge version tracking: badges must persist across +// every launch of the same app version, and only advance the next time the +// app itself updates — not clear after a single run. +// +// Run with: flutter test test/whats_new_service_test.dart + +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:vapourbox/services/whats_new_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final service = WhatsNewService.instance; + + void setCurrentVersion(String version) { + PackageInfo.setMockInitialValues( + appName: 'VapourBox', + packageName: 'app.vapourbox.vapourbox', + version: version, + buildNumber: '1', + buildSignature: '', + ); + } + + setUp(() { + SharedPreferences.setMockInitialValues({}); + // The singleton carries state between tests. + service.resetForTesting(); + }); + + group('WhatsNewService', () { + test('fresh install flags nothing as new', () async { + setCurrentVersion('1.2.0'); + await service.initialize(); + + expect(service.isNew('0.1.0'), false); + expect(service.isNew('1.2.0'), false); + }); + + test('an upgrade from a build that predates this tracking is treated like ' + 'a fresh install', () async { + // Old single-key data with no lastRunAppVersion at all — as if this + // service shipped after the stored lastSeenAppVersion was written by + // something else, or simply never existed before. + SharedPreferences.setMockInitialValues({'lastSeenAppVersion': '1.1.0'}); + setCurrentVersion('1.2.0'); + await service.initialize(); + + expect(service.isNew('1.2.0'), false, + reason: 'no lastRunAppVersion means there is nothing reliable to ' + 'diff against yet, so this launch just seeds the baseline'); + }); + + test('the first launch after an update flags anything shipped since the ' + 'version being left behind', () async { + SharedPreferences.setMockInitialValues({ + 'lastRunAppVersion': '1.1.0', + 'lastSeenAppVersion': '1.1.0', + }); + setCurrentVersion('1.2.0'); + await service.initialize(); + + expect(service.isNew('1.2.0'), true, reason: 'added in the update just installed'); + expect(service.isNew('1.1.0'), false, reason: 'already present before this update'); + expect(service.isNew('1.0.0'), false, reason: 'shipped well before the update'); + }); + + test('badges persist across many launches of the same version', () async { + SharedPreferences.setMockInitialValues({ + 'lastRunAppVersion': '1.1.0', + 'lastSeenAppVersion': '1.1.0', + }); + setCurrentVersion('1.2.0'); + await service.initialize(); + expect(service.isNew('1.2.0'), true); + + // Close and reopen the app several times, still on 1.2.0 — nothing + // about the installed version has changed, so the badge should keep + // showing every time, not just the first. + for (var i = 0; i < 3; i++) { + service.resetForTesting(); + await service.initialize(); + expect(service.isNew('1.2.0'), true, reason: 'launch #${i + 2} at the same version'); + } + }); + + test('the next real update advances the baseline and clears the old badge', + () async { + // Already updated once (1.1.0 -> 1.2.0) and relaunched a few times. + SharedPreferences.setMockInitialValues({ + 'lastRunAppVersion': '1.2.0', + 'lastSeenAppVersion': '1.1.0', + }); + + // Now a second update lands: 1.2.0 -> 1.3.0. + setCurrentVersion('1.3.0'); + await service.initialize(); + + expect(service.isNew('1.2.0'), false, + reason: 'no longer new now that a further update has shipped'); + expect(service.isNew('1.3.0'), true, reason: 'added in the update just installed'); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('lastSeenAppVersion'), '1.2.0'); + expect(prefs.getString('lastRunAppVersion'), '1.3.0'); + }); + + test('untagged (null) is never new', () async { + SharedPreferences.setMockInitialValues({ + 'lastRunAppVersion': '1.1.0', + 'lastSeenAppVersion': '1.1.0', + }); + setCurrentVersion('1.2.0'); + await service.initialize(); + + expect(service.isNew(null), false); + }); + + test('handles a version bump across a multi-digit component', () async { + // Plain string comparison would put "1.10.0" before "1.9.0". + SharedPreferences.setMockInitialValues({ + 'lastRunAppVersion': '1.9.0', + 'lastSeenAppVersion': '1.9.0', + }); + setCurrentVersion('1.10.0'); + await service.initialize(); + + expect(service.isNew('1.10.0'), true); + }); + }); +} diff --git a/docs/FILTER_SCHEMA.md b/docs/FILTER_SCHEMA.md index 39688b1..1e1e639 100644 --- a/docs/FILTER_SCHEMA.md +++ b/docs/FILTER_SCHEMA.md @@ -20,7 +20,8 @@ app means writing one of these. This is the field reference. |-------|------|----------|-------------| | `$schema` | string | No | Schema URL, informational only | | `id` | string | **Yes** | Unique identifier (snake_case), must match the filter id used in the UI wiring | -| `version` | string | **Yes** | Semantic version, e.g. `"1.0.0"` | +| `version` | string | **Yes** | Semantic version, e.g. `"1.0.0"` — **this schema file's own revision**, unrelated to the app version below | +| `sinceAppVersion` | string | No | The **app** version (matching `pubspec.yaml`, e.g. `"1.2.0"`) this filter was introduced in. Purely cosmetic — see "NEW badges" below | | `name` | string | **Yes** | Display name | | `description` | string | No | One-line summary shown under the name | | `longDescription` | string | No | The **More** expander text — what the filter does and when to reach for it. Supports `\n\n` paragraphs | @@ -171,6 +172,7 @@ entirely and replaced by a line telling the user more exist in advanced mode. | `optional` | boolean | Adds an enable checkbox; when unticked the parameter is **omitted** from the generated script so the VapourSynth default applies | | `vapoursynth` | object | `{"name": "…"}` — the argument name in the function, when it differs from the schema key. **`name` is the only field**; there is no type-transform option | | `ui` | object | See below | +| `sinceAppVersion` | string | The app version this parameter was added/last meaningfully changed in, e.g. `"1.2.0"`. Purely cosmetic — see "NEW badges" below | > **`optional: true` has a converter consequence.** For a parameter that should > default to *off*, the `fromX()` converter in @@ -220,6 +222,52 @@ works the same way as one on a checkbox. > offer, can *never* be satisfied — so the control it guards is invisible > forever, which is the worse of the two silent failures. +## NEW badges + +`sinceAppVersion` on a filter (top-level) or a parameter tags it with the app +version it shipped in. `WhatsNewService` (`app/lib/services/whats_new_service.dart`) +compares it against the version the user had installed *before this launch* — +not the current one — and a "NEW" pill (`app/lib/widgets/new_badge.dart`) is +drawn next to the pass in the pass list, or next to the parameter's control in +the settings panel, whenever `sinceAppVersion` is newer than that. + +```json +{ + "sinceAppVersion": "1.2.0", + "parameters": { + "someNewOption": { + "type": "boolean", + "default": false, + "sinceAppVersion": "1.2.0", + "ui": { "label": "Some New Option" } + } + } +} +``` + +- **Purely cosmetic.** Nothing else reads this field — omitting it or getting + it wrong doesn't break the pipeline, it just badges (or fails to badge) + something in the UI. +- **Format matters for the comparison, even though nothing validates it at + parse time.** Use the same dotted-numeric form as `pubspec.yaml`'s version + (`"1.2.0"`, not `"v1.2.0"` or `"1.2"`) — `filter_schema_curation_test.dart` + lints shipped schemas for this. +- **Badges persist across every launch of the current version, and only + advance on the next real update** — `WhatsNewService` tracks the version + that was actually running last launch separately from the comparison + baseline, so the baseline only moves the moment those two differ (an update + just happened), then holds steady through as many future launches as the + user stays on that version. There's no "mark as seen" action to wire up, and + no badge that silently disappears after someone glances at the app once. +- **A fresh install sees no badges at all.** There's no "since you last + updated" for someone who has never had a previous version, so a first + launch would otherwise badge the entire app. +- **Set it once, when the feature ships** — a bump to the app version alone + doesn't move any badge; only editing (or adding) `sinceAppVersion` on the + specific filter/parameter does. Stamp it at the same time you bump + `pubspec.yaml`'s version for the release, and leave everything already + shipped alone. + ## Implementation readout In advanced mode each pass shows the VapourSynth calls it makes, so someone who From 633f7069c8d34cbe941129caddd2a22d4bd8d8a8 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Thu, 17 Sep 2026 20:04:44 +1000 Subject: [PATCH 2/2] Show release notes, add a Changes tab, and bump to 1.2.0 - UpdateAvailableDialog now displays the fetched release body instead of discarding it. - Settings gains a "Changes" tab: a vertical list of every app release (UpdateChecker.listReleases, filtered to vX.Y.Z tags only) with a "NEW" badge via WhatsNewService, and lazy per-version fetching of release notes (UpdateChecker.fetchReleaseNotes) on first selection. - Release notes render through a new small hand-rolled ReleaseNotesText widget (headings, lists, bold, inline code) instead of raw markdown text or a markdown package dependency. - Fixed Scripts/run-debug-macos.sh: the old xcodebuild-piped-through-grep step swallowed real build failures and silently fell back to launching a stale cached app bundle. Replaced with a direct `flutter build macos --debug` (needed on newer Flutter, which resolves several plugins via Swift Package Manager rather than CocoaPods) and real exit-code checking. - Bumped app version to 1.2.0. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014GLXdGLfPwgYjW1AkonGqN --- Scripts/run-debug-macos.sh | 59 +++--- app/lib/services/update_checker.dart | 177 ++++++++++++---- app/lib/views/settings/settings_dialog.dart | 214 +++++++++++++++++++- app/lib/views/update_available_dialog.dart | 21 ++ app/lib/widgets/release_notes_text.dart | 137 +++++++++++++ app/macos/Runner/Info.plist | 4 +- app/pubspec.yaml | 2 +- app/windows/runner/Runner.rc | 4 +- worker/Cargo.toml | 2 +- 9 files changed, 547 insertions(+), 73 deletions(-) create mode 100644 app/lib/widgets/release_notes_text.dart diff --git a/Scripts/run-debug-macos.sh b/Scripts/run-debug-macos.sh index 01ac5df..1d90394 100755 --- a/Scripts/run-debug-macos.sh +++ b/Scripts/run-debug-macos.sh @@ -2,7 +2,7 @@ # Build and run VapourBox debug app on macOS. # Usage: ./Scripts/run-debug-macos.sh [--skip-worker] [--skip-app] [--run-only] -set -e +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" @@ -46,42 +46,53 @@ pkill -f "vapourbox.app" 2>/dev/null || true # Build Rust worker (debug) if ! $SKIP_WORKER; then echo "==> Building worker (debug)..." - cd "$WORKER_DIR" - cargo build + (cd "$WORKER_DIR" && cargo build) echo " Worker built." fi -# Build Flutter app (debug) via xcodebuild +# Build Flutter app (debug). +# +# This calls `flutter build macos --debug` directly, not a raw `xcodebuild` +# invocation against Runner.xcworkspace — recent Flutter versions resolve +# several plugins (file_picker, package_info_plus, screen_retriever_macos, +# shared_preferences_foundation, url_launcher_macos, as of Flutter 3.47) via +# Swift Package Manager rather than CocoaPods, and only `flutter build` +# drives that resolution. A raw `xcodebuild -scheme Runner` call fails on +# those with "Unable to resolve module dependency", even with Pods-Runner +# built first (the fix for the *different*, older module-resolution issue +# this script used to work around). if ! $SKIP_APP; then echo "==> Building Flutter app (debug)..." cd "$APP_DIR" - flutter pub get --no-example > /dev/null 2>&1 - - cd "$APP_DIR/macos" - xcodebuild -workspace Runner.xcworkspace \ - -scheme Runner \ - -configuration Debug \ - build \ - ARCHS=arm64 \ - ONLY_ACTIVE_ARCH=YES \ - 2>&1 | grep -E '(error:|warning:|BUILD|Compiling)' || true - + flutter pub get --no-example > /dev/null + + # Remove what a previous run of *this script* injected below (worker + # binary, templates) before rebuilding. Left in place, they confuse + # Xcode's code-signing pass — "code object is not signed at all" on a + # leftover .vpy file it never created — which fails the whole build. + # Removing only these two paths (not the whole bundle) keeps Xcode's own + # incremental build cache intact. + rm -f "$DEBUG_APP/Contents/MacOS/vapourbox-worker" + rm -rf "$DEBUG_APP/Contents/MacOS/templates" + + # No `|| true` and no piping through `grep` here: either of those would + # swallow a real build failure (this is exactly how a previous version of + # this script silently fell back to launching a stale, months-old app + # bundle after `xcodebuild` failed). `set -e` above means a nonzero exit + # here stops the script immediately, with Flutter's own error output + # printed in full. + flutter build macos --debug echo " Flutter app built." fi -# Copy app bundle from DerivedData to Flutter build location -echo "==> Assembling debug bundle..." -DERIVED_APP=$(find ~/Library/Developer/Xcode/DerivedData -path "*/Runner-*/Build/Products/Debug/vapourbox.app" -maxdepth 5 2>/dev/null | head -1) -if [ -z "$DERIVED_APP" ]; then - echo "ERROR: Could not find built app in DerivedData. Build the app first." +if [ ! -d "$DEBUG_APP" ]; then + echo "ERROR: $DEBUG_APP does not exist." >&2 + echo "Run without --skip-app / --run-only first to build it." >&2 exit 1 fi -mkdir -p "$APP_DIR/build/macos/Build/Products/Debug" -rm -rf "$DEBUG_APP" -cp -R "$DERIVED_APP" "$DEBUG_APP" - # Copy worker binary +echo "==> Assembling debug bundle..." cp "$WORKER_DIR/target/debug/vapourbox-worker" "$DEBUG_APP/Contents/MacOS/" # Copy templates (includes pipe_source.py used by VapourSynth scripts) diff --git a/app/lib/services/update_checker.dart b/app/lib/services/update_checker.dart index 6477c7d..a490b58 100644 --- a/app/lib/services/update_checker.dart +++ b/app/lib/services/update_checker.dart @@ -24,11 +24,48 @@ class UpdateInfo { bool get isUpdateAvailable => _compareVersions(latestVersion, currentVersion) > 0; } -/// Service for checking GitHub releases for updates. +/// One entry in the app's release history, for the Settings "Changes" tab. +/// +/// Deliberately lightweight — no `body` here. The releases list endpoint does +/// return each release's full body, but fetching every past release's notes +/// just to populate a sidebar of version numbers is wasted bandwidth against +/// GitHub's rate limit; [UpdateChecker.fetchReleaseNotes] pulls a given +/// version's notes only once the user actually selects it. +class AppRelease { + /// The raw GitHub tag, e.g. `"v1.2.0"`. + final String tagName; + + /// [tagName] with the leading `v` stripped, e.g. `"1.2.0"` — comparable + /// against [WhatsNewService.isNew] and `pubspec.yaml`'s version. + final String version; + + /// The release's title, if it has one distinct from the tag. + final String? name; + + final String htmlUrl; + final DateTime? publishedAt; + + const AppRelease({ + required this.tagName, + required this.version, + this.name, + required this.htmlUrl, + this.publishedAt, + }); +} + +/// Service for checking GitHub releases for updates, and for browsing the +/// app's full release history (Settings → Changes). class UpdateChecker { static const _prefsKeyCheckForUpdates = 'check_for_updates'; static const _githubRepo = 'StuartCameronCode/VapourBox'; - static const _apiUrl = 'https://api.github.com/repos/$_githubRepo/releases/latest'; + static const _apiBase = 'https://api.github.com/repos/$_githubRepo'; + static const _apiUrl = '$_apiBase/releases/latest'; + + /// App release tags only — `vX.Y.Z` exactly. The repo also carries + /// `deps-vX.Y.Z` and `whisper-vX.Y.Z` tags for its two other release + /// trains, which must not show up as app versions. + static final RegExp _appVersionTag = RegExp(r'^v\d+\.\d+\.\d+$'); static UpdateChecker? _instance; static UpdateChecker get instance => _instance ??= UpdateChecker._(); @@ -51,65 +88,121 @@ class UpdateChecker { /// Returns null if no update is available or if check fails. Future checkForUpdates() async { try { - // Get current version final packageInfo = await PackageInfo.fromPlatform(); final currentVersion = packageInfo.version; - // Fetch latest release from GitHub (User-Agent required by GitHub API) - final client = await RhttpClient.create( - settings: const ClientSettings( - throwOnStatusCode: false, - ), + final json = await _getJson(_apiUrl); + if (json is! Map) return null; + + final tagName = json['tag_name'] as String?; + final htmlUrl = json['html_url'] as String?; + final body = json['body'] as String?; + final publishedAtStr = json['published_at'] as String?; + + if (tagName == null || htmlUrl == null) { + debugPrint('UpdateChecker: Invalid response from GitHub API'); + return null; + } + + // Parse version from tag (remove 'v' prefix if present) + final latestVersion = tagName.startsWith('v') ? tagName.substring(1) : tagName; + + final updateInfo = UpdateInfo( + currentVersion: currentVersion, + latestVersion: latestVersion, + releaseUrl: htmlUrl, + releaseNotes: body, + publishedAt: publishedAtStr != null ? DateTime.tryParse(publishedAtStr) : null, ); + if (updateInfo.isUpdateAvailable) { + debugPrint('UpdateChecker: Update available: $currentVersion -> $latestVersion'); + return updateInfo; + } else { + debugPrint('UpdateChecker: No update available (current: $currentVersion, latest: $latestVersion)'); + return null; + } + } catch (e) { + debugPrint('UpdateChecker: Error checking for updates: $e'); + return null; + } + } + + /// The app's full release history, newest first, for Settings → Changes. + /// Returns null on failure (network, rate limit, malformed response) so the + /// caller can distinguish "couldn't load" from "no releases exist". + Future?> listReleases() async { + try { + final json = await _getJson('$_apiBase/releases?per_page=100'); + if (json is! List) return null; + + final releases = []; + for (final entry in json) { + if (entry is! Map) continue; + final tagName = entry['tag_name'] as String?; + final htmlUrl = entry['html_url'] as String?; + if (tagName == null || htmlUrl == null) continue; + if (!_appVersionTag.hasMatch(tagName)) continue; + if (entry['draft'] == true || entry['prerelease'] == true) continue; + + final publishedAtStr = entry['published_at'] as String?; + releases.add(AppRelease( + tagName: tagName, + version: tagName.substring(1), + name: entry['name'] as String?, + htmlUrl: htmlUrl, + publishedAt: publishedAtStr != null ? DateTime.tryParse(publishedAtStr) : null, + )); + } + return releases; + } catch (e) { + debugPrint('UpdateChecker: Error listing releases: $e'); + return null; + } + } + + /// A single release's notes, fetched lazily — only called once the user + /// actually selects that version in the Changes tab. Returns null on + /// failure or when the release has no body. + Future fetchReleaseNotes(String tagName) async { + try { + final json = await _getJson('$_apiBase/releases/tags/$tagName'); + if (json is! Map) return null; + final body = json['body'] as String?; + return (body == null || body.trim().isEmpty) ? null : body; + } catch (e) { + debugPrint('UpdateChecker: Error fetching release notes for $tagName: $e'); + return null; + } + } + + /// GET a GitHub API URL and decode the JSON body. Returns null on any + /// non-200 response or transport failure — every caller here treats that + /// the same way (fail soft, nothing loads), so the error handling lives + /// once rather than once per endpoint. + Future _getJson(String url) async { + try { + final client = await RhttpClient.create( + settings: const ClientSettings(throwOnStatusCode: false), + ); try { final response = await client.get( - _apiUrl, + url, headers: HttpHeaders.rawMap({ 'User-Agent': 'VapourBox-UpdateChecker/1.0', 'Accept': 'application/vnd.github.v3+json', }), ); - if (response.statusCode != 200) { - debugPrint('UpdateChecker: GitHub API returned ${response.statusCode}'); - return null; - } - - final json = jsonDecode(response.body) as Map; - final tagName = json['tag_name'] as String?; - final htmlUrl = json['html_url'] as String?; - final body = json['body'] as String?; - final publishedAtStr = json['published_at'] as String?; - - if (tagName == null || htmlUrl == null) { - debugPrint('UpdateChecker: Invalid response from GitHub API'); - return null; - } - - // Parse version from tag (remove 'v' prefix if present) - final latestVersion = tagName.startsWith('v') ? tagName.substring(1) : tagName; - - final updateInfo = UpdateInfo( - currentVersion: currentVersion, - latestVersion: latestVersion, - releaseUrl: htmlUrl, - releaseNotes: body, - publishedAt: publishedAtStr != null ? DateTime.tryParse(publishedAtStr) : null, - ); - - if (updateInfo.isUpdateAvailable) { - debugPrint('UpdateChecker: Update available: $currentVersion -> $latestVersion'); - return updateInfo; - } else { - debugPrint('UpdateChecker: No update available (current: $currentVersion, latest: $latestVersion)'); + debugPrint('UpdateChecker: GitHub API returned ${response.statusCode} for $url'); return null; } + return jsonDecode(response.body); } finally { client.dispose(); } } catch (e) { - debugPrint('UpdateChecker: Error checking for updates: $e'); + debugPrint('UpdateChecker: request to $url failed: $e'); return null; } } diff --git a/app/lib/views/settings/settings_dialog.dart b/app/lib/views/settings/settings_dialog.dart index 734dd29..2c57fa1 100644 --- a/app/lib/views/settings/settings_dialog.dart +++ b/app/lib/views/settings/settings_dialog.dart @@ -15,8 +15,11 @@ import '../../services/hardware_encoder_detector.dart'; import '../../services/overwrite_behavior_service.dart'; import '../../services/temp_directory_service.dart'; import '../../services/update_checker.dart'; +import '../../services/whats_new_service.dart'; import '../../utils/pixel_format.dart'; import '../../viewmodels/main_viewmodel.dart'; +import '../../widgets/new_badge.dart'; +import '../../widgets/release_notes_text.dart'; import '../../widgets/warning_banner.dart'; /// The output colour format explanation, shown in a scrollable dialog. @@ -311,7 +314,7 @@ class _SettingsDialogState extends State @override void initState() { super.initState(); - _tabController = TabController(length: 3, vsync: this); + _tabController = TabController(length: 4, vsync: this); } @override @@ -363,6 +366,7 @@ class _SettingsDialogState extends State Tab(text: 'General'), Tab(text: 'Output'), Tab(text: 'Input'), + Tab(text: 'Changes'), ], ), @@ -374,6 +378,7 @@ class _SettingsDialogState extends State _GeneralSettingsTab(), _OutputSettingsTab(), _InputSettingsTab(), + _ChangesSettingsTab(), ], ), ), @@ -489,6 +494,213 @@ class _InputSettingsTab extends StatelessWidget { } } +/// The app's release history, browsable by version — a vertical list of +/// versions on the left (the "sub-tabs"), the selected one's notes on the +/// right. Only the version list is fetched up front; each release's notes +/// are pulled lazily the first time it's actually selected, and cached in +/// memory afterward so re-selecting is instant. +class _ChangesSettingsTab extends StatefulWidget { + const _ChangesSettingsTab(); + + @override + State<_ChangesSettingsTab> createState() => _ChangesSettingsTabState(); +} + +class _ChangesSettingsTabState extends State<_ChangesSettingsTab> { + List? _releases; + bool _loadingList = true; + + String? _selectedTag; + final Map _notesCache = {}; + bool _loadingNotes = false; + + @override + void initState() { + super.initState(); + _loadReleases(); + } + + Future _loadReleases() async { + final releases = await UpdateChecker.instance.listReleases(); + if (!mounted) return; + setState(() { + _releases = releases; + _loadingList = false; + }); + if (releases != null && releases.isNotEmpty) { + _selectRelease(releases.first.tagName); + } + } + + Future _selectRelease(String tagName) async { + setState(() => _selectedTag = tagName); + if (_notesCache.containsKey(tagName)) return; + + setState(() => _loadingNotes = true); + final notes = await UpdateChecker.instance.fetchReleaseNotes(tagName); + if (!mounted) return; + setState(() { + _notesCache[tagName] = notes; + _loadingNotes = false; + }); + } + + Future _openUrl(String url) async { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + + String _formatDate(DateTime date) { + return '${date.year.toString().padLeft(4, '0')}-' + '${date.month.toString().padLeft(2, '0')}-' + '${date.day.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + if (_loadingList) { + return const Center(child: CircularProgressIndicator()); + } + + final releases = _releases; + if (releases == null || releases.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + releases == null + ? "Couldn't load release history. Check your connection and " + 'reopen Settings to try again.' + : 'No release history found.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: + Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ), + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: 140, + child: Container( + decoration: BoxDecoration( + border: Border( + right: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + ), + ), + ), + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: releases.length, + itemBuilder: (context, index) => + _buildVersionRow(context, releases[index]), + ), + ), + ), + Expanded(child: _buildDetail(context)), + ], + ); + } + + Widget _buildVersionRow(BuildContext context, AppRelease release) { + final selected = release.tagName == _selectedTag; + final isNew = WhatsNewService.instance.isNew(release.version); + final colorScheme = Theme.of(context).colorScheme; + + return InkWell( + onTap: () => _selectRelease(release.tagName), + child: Container( + color: selected + ? colorScheme.primaryContainer.withValues(alpha: 0.4) + : null, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Expanded( + child: Text( + 'v${release.version}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: selected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + if (isNew) ...[ + const SizedBox(width: 6), + const NewBadge(), + ], + ], + ), + ), + ); + } + + Widget _buildDetail(BuildContext context) { + final selectedTag = _selectedTag; + if (selectedTag == null) return const SizedBox.shrink(); + + final release = _releases!.firstWhere((r) => r.tagName == selectedTag); + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + (release.name?.trim().isNotEmpty ?? false) + ? release.name! + : 'v${release.version}', + style: theme.textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + IconButton( + icon: const Icon(Icons.open_in_new, size: 18), + tooltip: 'View on GitHub', + onPressed: () => _openUrl(release.htmlUrl), + ), + ], + ), + if (release.publishedAt != null) + Padding( + padding: const EdgeInsets.only(top: 2, bottom: 12), + child: Text( + _formatDate(release.publishedAt!), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ), + Expanded( + child: _loadingNotes + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + child: _notesCache[selectedTag] != null + ? ReleaseNotesText(markdown: _notesCache[selectedTag]!) + : Text( + 'No release notes for this version.', + style: theme.textTheme.bodyMedium + ?.copyWith(height: 1.4), + ), + ), + ), + ], + ), + ); + } +} + class _OutputSettingsTab extends StatefulWidget { const _OutputSettingsTab(); diff --git a/app/lib/views/update_available_dialog.dart b/app/lib/views/update_available_dialog.dart index 1f007ab..e7b3f56 100644 --- a/app/lib/views/update_available_dialog.dart +++ b/app/lib/views/update_available_dialog.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; import '../services/update_checker.dart'; +import '../widgets/release_notes_text.dart'; /// Dialog shown when a new version is available. class UpdateAvailableDialog extends StatelessWidget { @@ -94,6 +95,26 @@ class UpdateAvailableDialog extends StatelessWidget { ], ), ), + + if (updateInfo.releaseNotes != null && + updateInfo.releaseNotes!.trim().isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + "What's new", + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 220), + child: Scrollbar( + child: SingleChildScrollView( + child: ReleaseNotesText(markdown: updateInfo.releaseNotes!), + ), + ), + ), + ], ], ), ), diff --git a/app/lib/widgets/release_notes_text.dart b/app/lib/widgets/release_notes_text.dart new file mode 100644 index 0000000..5dfb742 --- /dev/null +++ b/app/lib/widgets/release_notes_text.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; + +/// Renders a GitHub release body as plain formatted text — headings, bullet +/// and numbered lists, bold, inline code, and horizontal rules — without a +/// markdown package. GitHub release notes are almost always just that +/// handful of block types, so a small line-by-line pass covers what's +/// actually shown; anything it doesn't recognize (tables, images, nested +/// blockquotes) just falls through as a plain paragraph rather than being +/// misrendered. +/// +/// Wrapped in a [SelectionArea] so the rendered text stays copyable, the one +/// thing a plain [SelectableText] gave up by going through this instead. +class ReleaseNotesText extends StatelessWidget { + final String markdown; + + const ReleaseNotesText({super.key, required this.markdown}); + + @override + Widget build(BuildContext context) { + return SelectionArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: _buildBlocks(context), + ), + ); + } + + List _buildBlocks(BuildContext context) { + final theme = Theme.of(context); + final lines = markdown.replaceAll('\r\n', '\n').split('\n'); + final blocks = []; + + final heading = RegExp(r'^(#{1,6})\s+(.*)'); + final bullet = RegExp(r'^[-*]\s+(.*)'); + final numbered = RegExp(r'^\d+\.\s+(.*)'); + final rule = RegExp(r'^(-{3,}|\*{3,}|_{3,})$'); + + for (final rawLine in lines) { + final line = rawLine.trim(); + + if (line.isEmpty) { + blocks.add(const SizedBox(height: 8)); + continue; + } + + if (rule.hasMatch(line)) { + blocks.add(const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Divider(height: 1), + )); + continue; + } + + final headingMatch = heading.firstMatch(line); + if (headingMatch != null) { + final level = headingMatch.group(1)!.length; + blocks.add(Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: _richText( + headingMatch.group(2)!, + theme, + base: (level == 1 + ? theme.textTheme.titleMedium + : level == 2 + ? theme.textTheme.titleSmall + : theme.textTheme.bodyLarge) + ?.copyWith(fontWeight: FontWeight.bold), + ), + )); + continue; + } + + final bulletMatch = bullet.firstMatch(line); + final numberedMatch = numbered.firstMatch(line); + if (bulletMatch != null || numberedMatch != null) { + final marker = bulletMatch != null + ? '•' + : '${line.split('.').first}.'; + final text = (bulletMatch ?? numberedMatch)!.group(1)!; + blocks.add(Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 22, + child: Text(marker, style: theme.textTheme.bodyMedium), + ), + Expanded(child: _richText(text, theme)), + ], + ), + )); + continue; + } + + blocks.add(Padding( + padding: const EdgeInsets.only(bottom: 4), + child: _richText(line, theme), + )); + } + + return blocks; + } + + /// Inline `**bold**` and `` `code` `` within one line of text. + Widget _richText(String text, ThemeData theme, {TextStyle? base}) { + final baseStyle = base ?? theme.textTheme.bodyMedium?.copyWith(height: 1.4); + final pattern = RegExp(r'\*\*(.+?)\*\*|`(.+?)`'); + + final spans = []; + var last = 0; + for (final match in pattern.allMatches(text)) { + if (match.start > last) { + spans.add(TextSpan(text: text.substring(last, match.start))); + } + final bold = match.group(1); + final code = match.group(2); + if (bold != null) { + spans.add(TextSpan( + text: bold, + style: const TextStyle(fontWeight: FontWeight.bold), + )); + } else if (code != null) { + spans.add(TextSpan( + text: code, + style: const TextStyle(fontFamily: 'monospace'), + )); + } + last = match.end; + } + if (last < text.length) { + spans.add(TextSpan(text: text.substring(last))); + } + + return RichText(text: TextSpan(style: baseStyle, children: spans)); + } +} diff --git a/app/macos/Runner/Info.plist b/app/macos/Runner/Info.plist index f38dfc9..9b56b4c 100644 --- a/app/macos/Runner/Info.plist +++ b/app/macos/Runner/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.1.0 + 1.2.0 CFBundleVersion - 1.1.0 + 1.2.0