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/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/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/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/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/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/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/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/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/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