From e539f08b130915c7b236c961bf5cf7ddbb7819ca Mon Sep 17 00:00:00 2001 From: phibvcfc Date: Sun, 12 Jul 2026 01:49:35 +0700 Subject: [PATCH 1/3] feat: implement SDK version auto-fetcher and update localization strings for version check UI --- lib/components/feedback/lib_update_tips.dart | 315 +++++++++++++++++- lib/core/providers/sdk_versions_provider.dart | 166 +++++++++ lib/core/utils/sdk_version.dart | 21 ++ lib/l10n/app_en.arb | 5 + lib/l10n/app_fr.arb | 5 + lib/l10n/app_ja.arb | 5 + lib/l10n/app_localizations.dart | 30 ++ lib/l10n/app_localizations_en.dart | 15 + lib/l10n/app_localizations_fr.dart | 15 + lib/l10n/app_localizations_ja.dart | 15 + lib/l10n/app_localizations_vi.dart | 15 + lib/l10n/app_localizations_zh.dart | 45 +++ lib/l10n/app_vi.arb | 5 + lib/l10n/app_zh.arb | 5 + lib/l10n/app_zh_CN.arb | 5 + lib/l10n/app_zh_TW.arb | 5 + pubspec.yaml | 1 + 17 files changed, 663 insertions(+), 10 deletions(-) create mode 100644 lib/core/providers/sdk_versions_provider.dart create mode 100644 lib/core/utils/sdk_version.dart diff --git a/lib/components/feedback/lib_update_tips.dart b/lib/components/feedback/lib_update_tips.dart index bd7b36c..68074b9 100644 --- a/lib/components/feedback/lib_update_tips.dart +++ b/lib/components/feedback/lib_update_tips.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../core/providers/sdk_versions_provider.dart'; +import '../../core/utils/sdk_version.dart'; import '../../l10n/app_localizations.dart'; /// Compact, dismissible "Tips" pill anchored at the top-right of the @@ -16,17 +19,23 @@ import '../../l10n/app_localizations.dart'; /// - **Self-dismiss**: panel collapses back to the dot when the /// mouse leaves; we never block the screen. /// -/// Versions mirror what `client_sdks//...` currently ships. -/// When you bump a version there, bump the same number in -/// `_SdkCatalog` below. -class LibUpdateTips extends StatefulWidget { +/// Each row shows: +/// - **Installed** version (hardcoded below in `_SdkCatalog`). +/// - **Latest** version, fetched live from npm / pub.dev by +/// [sdkLatestVersionsProvider]. Skipped entirely on fetch failure. +/// - **Update** chip in amber when installed < latest. +/// +/// When a new SDK ships, bump the matching `_SdkCatalog` entry. The +/// panel will keep showing the bumped number against whatever the +/// registry says is newest. +class LibUpdateTips extends ConsumerStatefulWidget { const LibUpdateTips({super.key}); @override - State createState() => _LibUpdateTipsState(); + ConsumerState createState() => _LibUpdateTipsState(); } -class _LibUpdateTipsState extends State { +class _LibUpdateTipsState extends ConsumerState { bool _hovered = false; @override @@ -35,6 +44,10 @@ class _LibUpdateTipsState extends State { final isDark = theme.brightness == Brightness.dark; final loc = S.of(context); final accent = const Color(0xFFFBBF24); // amber — advisory, never error + // Touching the provider here also kicks off the initial fetch — + // the StateNotifier's constructor schedules it as soon as it's + // first watched. + final latestVersions = ref.watch(sdkLatestVersionsProvider); return MouseRegion( onEnter: (_) => setState(() => _hovered = true), @@ -43,7 +56,7 @@ class _LibUpdateTipsState extends State { duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, height: _hovered ? null : 28, - width: _hovered ? 340 : 80, + width: _hovered ? 360 : 80, padding: EdgeInsets.symmetric( horizontal: _hovered ? 14 : 11, vertical: _hovered ? 12 : 6, @@ -90,6 +103,9 @@ class _LibUpdateTipsState extends State { isDark: isDark, loc: loc, sdkList: _SdkCatalog.entries, + latestVersions: latestVersions, + onRetry: () => + ref.read(sdkLatestVersionsProvider.notifier).refresh(), ) : _CollapsedPill(accent: accent, isDark: isDark, loc: loc), ), @@ -142,12 +158,16 @@ class _ExpandedPanel extends StatelessWidget { final bool isDark; final S loc; final List<_SdkEntry> sdkList; + final SdkLatestVersions latestVersions; + final VoidCallback onRetry; const _ExpandedPanel({ required this.accent, required this.isDark, required this.loc, required this.sdkList, + required this.latestVersions, + required this.onRetry, }); @override @@ -195,7 +215,15 @@ class _ExpandedPanel extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ for (final sdk in sdkList) ...[ - _SdkRow(entry: sdk, isDark: isDark, loc: loc), + _SdkRow( + entry: sdk, + isDark: isDark, + loc: loc, + accent: accent, + status: _statusFor(sdk), + latest: _latestFor(sdk), + onRetry: onRetry, + ), const SizedBox(height: 6), ], ], @@ -203,6 +231,23 @@ class _ExpandedPanel extends StatelessWidget { ], ); } + + String? _latestFor(_SdkEntry e) => switch (e.platform) { + _SdkPlatform.flutter => latestVersions.flutter, + _SdkPlatform.reactNative => latestVersions.reactNative, + // Android: pretend installed == latest so the row renders as + // up-to-date. Avoids a permanent spinner on a platform we + // don't fetch yet. + _SdkPlatform.android => e.version, + }; + + SdkVersionFetch _statusFor(_SdkEntry e) => switch (e.platform) { + // Android has no live fetch wired in — show the row as + // "loaded / up-to-date" using the installed version rather + // than a permanent loading spinner. + _SdkPlatform.android => SdkVersionFetch.loaded, + _ => latestVersions.fetchStateFor(e.platform == _SdkPlatform.flutter), + }; } // ─── SDK entry data ────────────────────────────────────────────────── @@ -252,26 +297,63 @@ class _SdkRow extends StatefulWidget { final _SdkEntry entry; final bool isDark; final S loc; + final Color accent; + final String? latest; + final SdkVersionFetch status; + final VoidCallback onRetry; const _SdkRow({ required this.entry, required this.isDark, required this.loc, + required this.accent, + required this.latest, + required this.status, + required this.onRetry, }); @override State<_SdkRow> createState() => _SdkRowState(); } -class _SdkRowState extends State<_SdkRow> { +class _SdkRowState extends State<_SdkRow> + with SingleTickerProviderStateMixin { bool _hovered = false; + late final AnimationController _loadingSpin; + + @override + void initState() { + super.initState(); + // Perpetual rotation for the loading indicator. Keeps the panel + // feeling "alive" while the registry fetch is in flight, so the + // user can see this row isn't the same as the offline state. + _loadingSpin = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1400), + )..repeat(); + } + + @override + void dispose() { + _loadingSpin.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { final e = widget.entry; final isDark = widget.isDark; final loc = widget.loc; - const accent = Color(0xFFFBBF24); + final accent = widget.accent; + final latest = widget.latest; + final status = widget.status; + + // Default to "up to date" when we have no live data — never + // flash an "Update" badge off a null/empty fetch. + final hasUpdate = latest != null && + compareSdkVersions(e.version, latest) < 0; + final isUpToDate = + latest != null && !hasUpdate && e.version == latest; String platformLabel(_SdkPlatform p) { switch (p) { @@ -360,6 +442,141 @@ class _SdkRowState extends State<_SdkRow> { ), ], ), + // Second-line status strip. Three render branches: + // loading — spinner + muted "Checking" + // error — cloud-off + "Live check unavailable" + Retry + // loaded — version pill (amber if outdated) + Update chip + // The separator "→" anchors the strip visually so the eye + // reads it as a continuation of the row above. + const SizedBox(height: 6), + Padding( + padding: const EdgeInsets.only(left: 2), + child: Row( + children: [ + Text( + '→', + style: TextStyle( + fontSize: 10, + fontFamily: 'monospace', + color: isDark ? Colors.white38 : Colors.black45, + ), + ), + const SizedBox(width: 6), + if (status == SdkVersionFetch.loading) ...[ + RotationTransition( + turns: _loadingSpin, + child: Icon( + LucideIcons.loader, + size: 11, + color: isDark ? Colors.white54 : Colors.black54, + ), + ), + const SizedBox(width: 6), + Text( + loc.sdkTipsChecking, + style: TextStyle( + fontSize: 9.5, + color: isDark ? Colors.white54 : Colors.black54, + ), + ), + ] else if (status == SdkVersionFetch.error) ...[ + Icon( + LucideIcons.cloudOff, + size: 11, + color: isDark ? Colors.white38 : Colors.black45, + ), + const SizedBox(width: 6), + Text( + loc.sdkTipsOffline, + style: TextStyle( + fontSize: 9.5, + color: isDark ? Colors.white38 : Colors.black45, + ), + ), + const SizedBox(width: 6), + _RetryChip( + label: loc.sdkTipsRetry, + isDark: isDark, + onTap: widget.onRetry, + ), + ] else ...[ + Text( + loc.sdkTipsLatestLabel, + style: TextStyle( + fontSize: 9.5, + color: isDark ? Colors.white54 : Colors.black54, + ), + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: hasUpdate + ? accent.withValues(alpha: isDark ? 0.16 : 0.14) + : (isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.05)), + borderRadius: BorderRadius.circular(4), + border: hasUpdate + ? Border.all( + color: accent.withValues(alpha: 0.4), + width: 0.7, + ) + : null, + ), + child: Text( + loc.sdkTipsVersionLabel(latest!), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + fontFamily: 'monospace', + color: hasUpdate + ? accent + : (isDark + ? Colors.white70 + : Colors.black87), + ), + ), + ), + if (hasUpdate) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: accent, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + loc.sdkTipsUpdate, + style: TextStyle( + fontSize: 8.5, + fontWeight: FontWeight.w800, + letterSpacing: 0.4, + color: Colors.black87, + ), + ), + ), + ], + if (isUpToDate) ...[ + const SizedBox(width: 6), + Text( + // Tiny "✓" hint when the user is on the current + // version — confirms the data is live. + '✓', + style: TextStyle( + fontSize: 10, + color: isDark + ? const Color(0xFF4ADE80) + : const Color(0xFF16A34A), + ), + ), + ], + ], + ], + ), + ), const SizedBox(height: 4), Padding( padding: const EdgeInsets.only(left: 2), @@ -377,4 +594,82 @@ class _SdkRowState extends State<_SdkRow> { ), ); } +} + +/// Compact pill button shown next to the "Live check unavailable" +/// caption on the offline row. Click → `onTap`, which the parent +/// wires to `sdkLatestVersionsProvider.notifier.refresh()`. +/// +/// Visually a tiny ghost chip: muted text on a tinted glass surface, +/// 1px inner border for the "liquid glass" refraction feel. Slight +/// scale-down on press for tactile feedback (skill rule: physical +/// push indicating success/action). +class _RetryChip extends StatefulWidget { + final String label; + final bool isDark; + final VoidCallback onTap; + + const _RetryChip({ + required this.label, + required this.isDark, + required this.onTap, + }); + + @override + State<_RetryChip> createState() => _RetryChipState(); +} + +class _RetryChipState extends State<_RetryChip> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + final isDark = widget.isDark; + return GestureDetector( + onTapDown: (_) => setState(() => _pressed = true), + onTapCancel: () => setState(() => _pressed = false), + onTapUp: (_) => setState(() => _pressed = false), + onTap: widget.onTap, + child: AnimatedScale( + scale: _pressed ? 0.96 : 1.0, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.12) + : Colors.black.withValues(alpha: 0.08), + width: 0.7, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.refreshCw, + size: 9, + color: isDark ? Colors.white70 : Colors.black87, + ), + const SizedBox(width: 4), + Text( + widget.label, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 0.3, + color: isDark ? Colors.white70 : Colors.black87, + ), + ), + ], + ), + ), + ), + ); + } } \ No newline at end of file diff --git a/lib/core/providers/sdk_versions_provider.dart b/lib/core/providers/sdk_versions_provider.dart new file mode 100644 index 0000000..b96dfb5 --- /dev/null +++ b/lib/core/providers/sdk_versions_provider.dart @@ -0,0 +1,166 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; + +/// Latest published versions for the SDKs DevConnect Manage Tool +/// supports. Fetched on construction from npm (React Native) and +/// pub.dev (Flutter), then auto-refreshed every [_ttl]. +/// +/// Designed to be fault-tolerant: a network failure, timeout, or +/// malformed payload on one registry leaves that platform's [value] +/// null and records an [error]. The UI falls back to showing only the +/// hardcoded installed versions in that case — no crash, no spinner. +/// +/// Android (`com.devconnect:devconnect`) is intentionally NOT fetched +/// here — there's no Maven Central fetch wired into this provider. +class SdkLatestVersions { + final String? flutter; + final String? reactNative; + final DateTime? fetchedAt; + final String? flutterError; + final String? reactNativeError; + + const SdkLatestVersions({ + this.flutter, + this.reactNative, + this.fetchedAt, + this.flutterError, + this.reactNativeError, + }); + + static const empty = SdkLatestVersions(); + + bool get hasAny => flutter != null || reactNative != null; + + String? latestFor(bool isFlutter) => isFlutter ? flutter : reactNative; + String? errorFor(bool isFlutter) => isFlutter ? flutterError : reactNativeError; + + /// Per-platform fetch state — used by the UI to decide between the + /// loading shimmer, the offline retry, and the loaded (or up-to-date) + /// pill. + /// + /// - [SdkVersionFetch.loaded] when we have a parsed version + /// - [SdkVersionFetch.error] when the latest fetch failed + /// - [SdkVersionFetch.loading] when we haven't heard back yet (first + /// fetch, or right after a manual retry) + SdkVersionFetch fetchStateFor(bool isFlutter) { + final value = isFlutter ? flutter : reactNative; + final err = isFlutter ? flutterError : reactNativeError; + if (value != null) return SdkVersionFetch.loaded; + if (err != null) return SdkVersionFetch.error; + return SdkVersionFetch.loading; + } +} + +enum SdkVersionFetch { loading, loaded, error } + +final sdkLatestVersionsProvider = + StateNotifierProvider((ref) { + final notifier = SdkVersionsNotifier(); + ref.onDispose(notifier._onDispose); + return notifier; +}); + +class SdkVersionsNotifier extends StateNotifier { + /// Refresh cadence. Desktop apps stay open for hours; refreshing + /// every 30 min keeps the "Latest" pill current without spamming + /// the registries. + static const _ttl = Duration(minutes: 30); + + /// Per-request timeout. npm and pub.dev are both fast — 5 s is + /// generous and prevents the panel from sitting on a stale "loading" + /// state if the user's network is wedged. + static const _timeout = Duration(seconds: 5); + + /// Endpoints. The npm `/latest` shortcut returns only the latest + /// release (small payload). pub.dev's `/api/packages/` is the + /// canonical metadata endpoint and includes a `latest` block. + static const _npmUrl = + 'https://registry.npmjs.org/devconnect-manage-kit/latest'; + static const _pubUrl = + 'https://pub.dev/api/packages/devconnect_manage_kit'; + + Timer? _refresh; + + SdkVersionsNotifier() : super(SdkLatestVersions.empty) { + // Kick off the first fetch asynchronously so the StateNotifier + // constructor returns immediately (Riverpod expects sync init). + // ignore: discarded_futures + _refreshNow(); + _refresh = Timer.periodic(_ttl, (_) => _refreshNow()); + } + + /// Manually re-fetch. UI surfaces this as a "Retry" affordance when + /// a previous fetch errored out (offline / timeout / 5xx). + Future refresh() => _refreshNow(); + + Future _refreshNow() async { + // Run both fetches in parallel. Each is wrapped in its own + // try/catch so a failure on one platform doesn't poison the other. + final results = await Future.wait([_fetchNpm(), _fetchPub()]); + // Guard against "use after dispose": the widget tree holding us + // could tear down (user closes the panel mid-fetch). Without this + // check, the `state =` below would throw "Bad state: Cannot use a + // StateNotifier after its dispose()". + if (!mounted) return; + final npm = results[0]; // _fetchNpm() -> reactNative + final pub = results[1]; // _fetchPub() -> flutter + state = SdkLatestVersions( + flutter: pub.value, + reactNative: npm.value, + fetchedAt: DateTime.now(), + flutterError: pub.error, + reactNativeError: npm.error, + ); + } + + Future<_Result> _fetchNpm() async { + try { + final resp = + await http.get(Uri.parse(_npmUrl)).timeout(_timeout); + if (resp.statusCode != 200) { + return _Result.error('HTTP ${resp.statusCode}'); + } + final json = jsonDecode(resp.body); + final version = json is Map ? json['version'] : null; + if (version is String && version.isNotEmpty) return _Result.ok(version); + return _Result.error('missing "version" field'); + } on TimeoutException { + return _Result.error('timeout'); + } catch (e) { + return _Result.error(e.toString()); + } + } + + Future<_Result> _fetchPub() async { + try { + final resp = + await http.get(Uri.parse(_pubUrl)).timeout(_timeout); + if (resp.statusCode != 200) { + return _Result.error('HTTP ${resp.statusCode}'); + } + final json = jsonDecode(resp.body); + final latest = json is Map ? json['latest'] : null; + final version = latest is Map ? latest['version'] : null; + if (version is String && version.isNotEmpty) return _Result.ok(version); + return _Result.error('missing "latest.version" field'); + } on TimeoutException { + return _Result.error('timeout'); + } catch (e) { + return _Result.error(e.toString()); + } + } + + void _onDispose() { + _refresh?.cancel(); + } +} + +class _Result { + final String? value; + final String? error; + const _Result.ok(this.value) : error = null; + const _Result.error(this.error) : value = null; +} \ No newline at end of file diff --git a/lib/core/utils/sdk_version.dart b/lib/core/utils/sdk_version.dart new file mode 100644 index 0000000..88bd49d --- /dev/null +++ b/lib/core/utils/sdk_version.dart @@ -0,0 +1,21 @@ +/// Compare two semver-ish strings like `1.2.3` < `1.2.10`. +/// +/// Returns negative if [a] < [b], zero if equal, positive if [a] > [b]. +/// Missing components default to `0` (so `1.2` is treated as `1.2.0`). +/// Pre-release tags (`1.2.3-beta.1`) are ignored — fine for the SDKs we +/// ship, where every release is a stable `MAJOR.MINOR.PATCH`. +/// +/// Non-numeric components (e.g. `1.x.0`) are treated as `0` instead of +/// throwing — we don't want a malformed registry payload to crash the +/// "Update available" badge. +int compareSdkVersions(String a, String b) { + final pa = a.split('.'); + final pb = b.split('.'); + final length = pa.length > pb.length ? pa.length : pb.length; + for (var i = 0; i < length; i++) { + final ai = i < pa.length ? int.tryParse(pa[i]) ?? 0 : 0; + final bi = i < pb.length ? int.tryParse(pb[i]) ?? 0 : 0; + if (ai != bi) return ai - bi; + } + return 0; +} \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b9eb07e..1b7544d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -180,6 +180,11 @@ "sdkTipsReactNative": "React Native", "sdkTipsAndroid": "Android", "sdkTipsVersionLabel": "v{version}", + "sdkTipsLatestLabel": "Latest", + "sdkTipsUpdate": "Update", + "sdkTipsChecking": "Checking", + "sdkTipsOffline": "Live check unavailable", + "sdkTipsRetry": "Retry", "networkTitle": "Network", "filterUrls": "Filter URLs...", "copyUrl": "Copy URL", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index ebd8039..13bde79 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -180,6 +180,11 @@ "sdkTipsReactNative": "React Native", "sdkTipsAndroid": "Android", "sdkTipsVersionLabel": "v{version}", + "sdkTipsLatestLabel": "Récent", + "sdkTipsUpdate": "Mettre à jour", + "sdkTipsChecking": "Vérification", + "sdkTipsOffline": "Vérification indisponible", + "sdkTipsRetry": "Réessayer", "networkTitle": "Réseau", "filterUrls": "Filtrer les URL...", "copyUrl": "Copier l'URL", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 647acc0..56aab20 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -180,6 +180,11 @@ "sdkTipsReactNative": "React Native", "sdkTipsAndroid": "Android", "sdkTipsVersionLabel": "v{version}", + "sdkTipsLatestLabel": "最新", + "sdkTipsUpdate": "更新", + "sdkTipsChecking": "確認中", + "sdkTipsOffline": "確認できません", + "sdkTipsRetry": "再試行", "networkTitle": "ネットワーク", "filterUrls": "URLをフィルター...", "copyUrl": "URLをコピー", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c5c2601..8c0e485 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1185,6 +1185,36 @@ abstract class S { /// **'v{version}'** String sdkTipsVersionLabel(Object version); + /// No description provided for @sdkTipsLatestLabel. + /// + /// In en, this message translates to: + /// **'Latest'** + String get sdkTipsLatestLabel; + + /// No description provided for @sdkTipsUpdate. + /// + /// In en, this message translates to: + /// **'Update'** + String get sdkTipsUpdate; + + /// No description provided for @sdkTipsChecking. + /// + /// In en, this message translates to: + /// **'Checking'** + String get sdkTipsChecking; + + /// No description provided for @sdkTipsOffline. + /// + /// In en, this message translates to: + /// **'Live check unavailable'** + String get sdkTipsOffline; + + /// No description provided for @sdkTipsRetry. + /// + /// In en, this message translates to: + /// **'Retry'** + String get sdkTipsRetry; + /// No description provided for @networkTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 300d961..bcbdae9 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -660,6 +660,21 @@ class SEn extends S { return 'v$version'; } + @override + String get sdkTipsLatestLabel => 'Latest'; + + @override + String get sdkTipsUpdate => 'Update'; + + @override + String get sdkTipsChecking => 'Checking'; + + @override + String get sdkTipsOffline => 'Live check unavailable'; + + @override + String get sdkTipsRetry => 'Retry'; + @override String get networkTitle => 'Network'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index adb7ad2..46c6325 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -663,6 +663,21 @@ class SFr extends S { return 'v$version'; } + @override + String get sdkTipsLatestLabel => 'Récent'; + + @override + String get sdkTipsUpdate => 'Mettre à jour'; + + @override + String get sdkTipsChecking => 'Vérification'; + + @override + String get sdkTipsOffline => 'Vérification indisponible'; + + @override + String get sdkTipsRetry => 'Réessayer'; + @override String get networkTitle => 'Réseau'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index c7a66d6..d84f88f 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -656,6 +656,21 @@ class SJa extends S { return 'v$version'; } + @override + String get sdkTipsLatestLabel => '最新'; + + @override + String get sdkTipsUpdate => '更新'; + + @override + String get sdkTipsChecking => '確認中'; + + @override + String get sdkTipsOffline => '確認できません'; + + @override + String get sdkTipsRetry => '再試行'; + @override String get networkTitle => 'ネットワーク'; diff --git a/lib/l10n/app_localizations_vi.dart b/lib/l10n/app_localizations_vi.dart index 64bffbb..0c9f036 100644 --- a/lib/l10n/app_localizations_vi.dart +++ b/lib/l10n/app_localizations_vi.dart @@ -662,6 +662,21 @@ class SVi extends S { return 'v$version'; } + @override + String get sdkTipsLatestLabel => 'Mới nhất'; + + @override + String get sdkTipsUpdate => 'Cập nhật'; + + @override + String get sdkTipsChecking => 'Đang kiểm tra'; + + @override + String get sdkTipsOffline => 'Không kiểm tra được'; + + @override + String get sdkTipsRetry => 'Thử lại'; + @override String get networkTitle => 'Mạng'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index d2e3e8e..77244c5 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -644,6 +644,21 @@ class SZh extends S { return 'v$version'; } + @override + String get sdkTipsLatestLabel => '最新'; + + @override + String get sdkTipsUpdate => '更新'; + + @override + String get sdkTipsChecking => '检查中'; + + @override + String get sdkTipsOffline => '无法检查'; + + @override + String get sdkTipsRetry => '重试'; + @override String get networkTitle => '网络'; @@ -1743,6 +1758,21 @@ class SZhCn extends SZh { return 'v$version'; } + @override + String get sdkTipsLatestLabel => '最新'; + + @override + String get sdkTipsUpdate => '更新'; + + @override + String get sdkTipsChecking => '检查中'; + + @override + String get sdkTipsOffline => '无法检查'; + + @override + String get sdkTipsRetry => '重试'; + @override String get networkTitle => '网络'; @@ -2842,6 +2872,21 @@ class SZhTw extends SZh { return 'v$version'; } + @override + String get sdkTipsLatestLabel => '最新'; + + @override + String get sdkTipsUpdate => '更新'; + + @override + String get sdkTipsChecking => '檢查中'; + + @override + String get sdkTipsOffline => '無法檢查'; + + @override + String get sdkTipsRetry => '重試'; + @override String get networkTitle => '網路'; diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 2a48bdc..c06fce0 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -180,6 +180,11 @@ "sdkTipsReactNative": "React Native", "sdkTipsAndroid": "Android", "sdkTipsVersionLabel": "v{version}", + "sdkTipsLatestLabel": "Mới nhất", + "sdkTipsUpdate": "Cập nhật", + "sdkTipsChecking": "Đang kiểm tra", + "sdkTipsOffline": "Không kiểm tra được", + "sdkTipsRetry": "Thử lại", "networkTitle": "Mạng", "filterUrls": "Lọc URL...", "copyUrl": "Sao chép URL", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 224dca9..fe3ea31 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -180,6 +180,11 @@ "sdkTipsReactNative": "React Native", "sdkTipsAndroid": "Android", "sdkTipsVersionLabel": "v{version}", + "sdkTipsLatestLabel": "最新", + "sdkTipsUpdate": "更新", + "sdkTipsChecking": "检查中", + "sdkTipsOffline": "无法检查", + "sdkTipsRetry": "重试", "networkTitle": "网络", "filterUrls": "筛选 URL...", "copyUrl": "复制 URL", diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index d918452..500162e 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -182,6 +182,11 @@ "sdkTipsReactNative": "React Native", "sdkTipsAndroid": "Android", "sdkTipsVersionLabel": "v{version}", + "sdkTipsLatestLabel": "最新", + "sdkTipsUpdate": "更新", + "sdkTipsChecking": "检查中", + "sdkTipsOffline": "无法检查", + "sdkTipsRetry": "重试", "networkTitle": "网络", "filterUrls": "筛选 URL...", "copyUrl": "复制 URL", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 7d08e65..9846299 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -182,6 +182,11 @@ "sdkTipsReactNative": "React Native", "sdkTipsAndroid": "Android", "sdkTipsVersionLabel": "v{version}", + "sdkTipsLatestLabel": "最新", + "sdkTipsUpdate": "更新", + "sdkTipsChecking": "檢查中", + "sdkTipsOffline": "無法檢查", + "sdkTipsRetry": "重試", "networkTitle": "網路", "filterUrls": "篩選 URL...", "copyUrl": "複製 URL", diff --git a/pubspec.yaml b/pubspec.yaml index 577dbf7..f5895c7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: window_manager: ^0.4.3 file_selector_platform_interface: ^2.7.0 file_selector: ^1.1.0 + http: ^1.2.0 dev_dependencies: flutter_test: sdk: flutter From 5a57ee8913c63869445928347b0bfb6ab24b84c2 Mon Sep 17 00:00:00 2001 From: phibvcfc Date: Sun, 12 Jul 2026 02:15:24 +0700 Subject: [PATCH 2/3] feat: implement AppUpdatePill UI and version check providers to notify users of available updates --- lib/components/feedback/app_update_pill.dart | 522 ++++++++++++++++++ lib/core/providers/app_update_provider.dart | 173 ++++++ lib/core/providers/app_version_provider.dart | 18 + lib/core/routes/app_shell.dart | 12 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + pubspec.yaml | 2 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 8 files changed, 735 insertions(+) create mode 100644 lib/components/feedback/app_update_pill.dart create mode 100644 lib/core/providers/app_update_provider.dart create mode 100644 lib/core/providers/app_version_provider.dart diff --git a/lib/components/feedback/app_update_pill.dart b/lib/components/feedback/app_update_pill.dart new file mode 100644 index 0000000..51b2084 --- /dev/null +++ b/lib/components/feedback/app_update_pill.dart @@ -0,0 +1,522 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../core/providers/app_update_provider.dart'; + +/// Compact "app version" pill anchored to the LEFT side of the title +/// bar (the right-side `LibUpdateTips` covers the SDK packages). +/// +/// Mirrors the visual language of the SDK tip — amber accent for +/// "advisory, never error", mono font for version numbers, 1px inner +/// border for the liquid-glass refraction feel — but smaller and +/// only renders the desktop app's own version vs the latest GitHub +/// release. +/// +/// States: +/// - **Update available** (most important): pill shows current +/// version with an amber `Update` chip. Hover expands the panel +/// with the latest version and a `View release` button that opens +/// the GitHub release page in the default browser. +/// - **Up to date**: subtle green checkmark, no hover expansion. +/// - **Loading** (initial / right after Retry): spinner. +/// - **Error** (offline / rate limit / no release published yet): +/// cloud-off icon, hover expands with retry. +/// +/// The version comparison lives in [appUpdateProvider]; this widget +/// is presentation-only. +class AppUpdatePill extends ConsumerStatefulWidget { + const AppUpdatePill({super.key}); + + @override + ConsumerState createState() => _AppUpdatePillState(); +} + +class _AppUpdatePillState extends ConsumerState { + bool _hovered = false; + + static const _accent = Color(0xFFFBBF24); // amber — advisory + static const _releasePageFallback = + 'https://github.com/ridelinktechs/devconnect-manage-kit/releases/latest'; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final state = ref.watch(appUpdateProvider); + final release = state.release; + final hasUpdate = state.hasUpdate; + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + height: _hovered ? null : 26, + width: _hovered ? 320 : null, + padding: EdgeInsets.symmetric( + horizontal: _hovered ? 12 : 10, + vertical: _hovered ? 10 : 5, + ), + decoration: BoxDecoration( + color: _hovered + ? (isDark + ? const Color(0xFF1F242B).withValues(alpha: 0.96) + : Colors.white.withValues(alpha: 0.97)) + : (isDark + ? const Color(0xFF1F242B).withValues(alpha: 0.85) + : Colors.white.withValues(alpha: 0.92)), + borderRadius: BorderRadius.circular(13), + border: Border.all( + color: _hovered + ? (isDark + ? Colors.white.withValues(alpha: 0.10) + : Colors.black.withValues(alpha: 0.08)) + : (isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.05)), + ), + boxShadow: [ + if (_hovered) + BoxShadow( + color: _accent.withValues(alpha: isDark ? 0.18 : 0.12), + blurRadius: 16, + spreadRadius: -4, + offset: const Offset(0, 6), + ), + ], + ), + child: _hovered + ? _ExpandedPanel( + isDark: isDark, + state: state, + onOpenRelease: () => _openRelease(release?.htmlUrl), + onRetry: () => + ref.read(appUpdateProvider.notifier).refresh(), + ) + : _CollapsedPill( + isDark: isDark, + hasUpdate: hasUpdate, + release: release, + currentVersion: state.currentVersion, + errored: state.error != null && state.fetchedAt != null, + loading: state.fetchedAt == null, + ), + ), + ); + } + + Future _openRelease(String? url) async { + final target = url ?? _releasePageFallback; + final uri = Uri.parse(target); + // `externalApplication` asks the OS to open in the default browser + // (Safari on macOS). Falls back to in-app webview if no handler. + final ok = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!ok && mounted) { + // Last-ditch: try the in-app handler so the click is never + // a dead-end. If that also fails we silently bail — the + // hover panel stays visible with the URL it tried. + await launchUrl(uri, mode: LaunchMode.inAppWebView); + } + } +} + +// ─── Collapsed pill (rest state) ───────────────────────────────────── + +class _CollapsedPill extends StatefulWidget { + final bool isDark; + final bool hasUpdate; + final bool errored; + final bool loading; + final AppRelease? release; + final String? currentVersion; + + const _CollapsedPill({ + required this.isDark, + required this.hasUpdate, + required this.errored, + required this.loading, + required this.release, + required this.currentVersion, + }); + + @override + State<_CollapsedPill> createState() => _CollapsedPillState(); +} + +class _CollapsedPillState extends State<_CollapsedPill> + with SingleTickerProviderStateMixin { + late final AnimationController _spin; + + @override + void initState() { + super.initState(); + _spin = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1400), + ); + if (widget.loading) _spin.repeat(); + } + + @override + void didUpdateWidget(covariant _CollapsedPill old) { + super.didUpdateWidget(old); + if (widget.loading && !_spin.isAnimating) { + _spin.repeat(); + } else if (!widget.loading && _spin.isAnimating) { + _spin.stop(); + } + } + + @override + void dispose() { + _spin.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isDark = widget.isDark; + // Leading glyph — communicates the state without color alone so + // the pill stays readable in any theme. + Widget leading; + Color leadingColor; + if (widget.loading) { + leading = RotationTransition( + turns: _spin, + child: Icon(LucideIcons.loader, size: 11, color: _AppUpdatePillState._accent), + ); + leadingColor = _AppUpdatePillState._accent; + } else if (widget.hasUpdate) { + leading = Icon(LucideIcons.arrowUpCircle, size: 12, color: _AppUpdatePillState._accent); + leadingColor = _AppUpdatePillState._accent; + } else if (widget.errored) { + leading = Icon(LucideIcons.cloudOff, size: 11, color: isDark ? Colors.white54 : Colors.black54); + leadingColor = isDark ? Colors.white54 : Colors.black54; + } else { + leading = Icon( + LucideIcons.check, + size: 12, + color: isDark ? const Color(0xFF4ADE80) : const Color(0xFF16A34A), + ); + leadingColor = isDark ? const Color(0xFF4ADE80) : const Color(0xFF16A34A); + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + leading, + const SizedBox(width: 6), + // `v1.0.3` once package_info_plus resolves; falls back to `—` + // for the brief window between widget mount and platform + // channel response. + Text( + widget.currentVersion != null + ? 'v${widget.currentVersion}' + : '—', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + fontFamily: 'monospace', + letterSpacing: 0.2, + color: leadingColor, + ), + ), + if (widget.hasUpdate) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: _AppUpdatePillState._accent, + borderRadius: BorderRadius.circular(3), + ), + child: const Text( + 'UPDATE', + style: TextStyle( + fontSize: 8.5, + fontWeight: FontWeight.w800, + letterSpacing: 0.4, + color: Colors.black87, + ), + ), + ), + ], + ], + ); + } +} + +// ─── Expanded panel (hover state) ──────────────────────────────────── + +class _ExpandedPanel extends StatelessWidget { + final bool isDark; + final AppReleaseState state; + final VoidCallback onOpenRelease; + final VoidCallback onRetry; + + const _ExpandedPanel({ + required this.isDark, + required this.state, + required this.onOpenRelease, + required this.onRetry, + }); + + @override + Widget build(BuildContext context) { + final release = state.release; + final hasUpdate = state.hasUpdate; + final errored = state.error != null && state.fetchedAt != null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon(LucideIcons.appWindow, size: 12, color: _AppUpdatePillState._accent), + const SizedBox(width: 6), + Text( + hasUpdate + ? 'Update available' + : errored + ? 'Live check unavailable' + : 'You are up to date', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: isDark ? Colors.white : Colors.black87, + letterSpacing: 0.2, + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + _VersionCell( + label: 'Installed', + version: state.currentVersion != null + ? 'v${state.currentVersion}' + : '—', + isDark: isDark, + accent: false, + ), + const SizedBox(width: 10), + Icon( + LucideIcons.arrowRight, + size: 10, + color: isDark ? Colors.white38 : Colors.black45, + ), + const SizedBox(width: 10), + _VersionCell( + label: 'Latest', + version: release != null ? 'v${release.version}' : '—', + isDark: isDark, + accent: hasUpdate, + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _PrimaryActionButton( + label: 'View release', + icon: LucideIcons.externalLink, + isDark: isDark, + accent: hasUpdate, + onTap: onOpenRelease, + ), + ), + if (errored) ...[ + const SizedBox(width: 8), + _SecondaryActionButton( + label: 'Retry', + icon: LucideIcons.refreshCw, + isDark: isDark, + onTap: onRetry, + ), + ], + ], + ), + ], + ); + } +} + +class _VersionCell extends StatelessWidget { + final String label; + final String version; + final bool isDark; + final bool accent; + + const _VersionCell({ + required this.label, + required this.version, + required this.isDark, + required this.accent, + }); + + @override + Widget build(BuildContext context) { + final color = accent ? _AppUpdatePillState._accent : (isDark ? Colors.white : Colors.black87); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + fontSize: 8.5, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + color: isDark ? Colors.white38 : Colors.black45, + ), + ), + const SizedBox(height: 2), + Text( + version, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + fontFamily: 'monospace', + color: color, + ), + ), + ], + ); + } +} + +class _PrimaryActionButton extends StatefulWidget { + final String label; + final IconData icon; + final bool isDark; + final bool accent; + final VoidCallback onTap; + + const _PrimaryActionButton({ + required this.label, + required this.icon, + required this.isDark, + required this.accent, + required this.onTap, + }); + + @override + State<_PrimaryActionButton> createState() => _PrimaryActionButtonState(); +} + +class _PrimaryActionButtonState extends State<_PrimaryActionButton> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + final accent = widget.accent; + final isDark = widget.isDark; + return GestureDetector( + onTapDown: (_) => setState(() => _pressed = true), + onTapCancel: () => setState(() => _pressed = false), + onTapUp: (_) => setState(() => _pressed = false), + onTap: widget.onTap, + child: AnimatedScale( + scale: _pressed ? 0.97 : 1.0, + duration: const Duration(milliseconds: 110), + curve: Curves.easeOut, + child: Container( + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: accent + ? _AppUpdatePillState._accent + : (isDark + ? Colors.white.withValues(alpha: 0.10) + : Colors.black.withValues(alpha: 0.06)), + borderRadius: BorderRadius.circular(6), + border: accent + ? null + : Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.10) + : Colors.black.withValues(alpha: 0.06), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + widget.icon, + size: 11, + color: accent ? Colors.black87 : (isDark ? Colors.white : Colors.black87), + ), + const SizedBox(width: 5), + Text( + widget.label, + style: TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w800, + letterSpacing: 0.3, + color: accent ? Colors.black87 : (isDark ? Colors.white : Colors.black87), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _SecondaryActionButton extends StatelessWidget { + final String label; + final IconData icon; + final bool isDark; + final VoidCallback onTap; + + const _SecondaryActionButton({ + required this.label, + required this.icon, + required this.isDark, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.10) + : Colors.black.withValues(alpha: 0.08), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 11, color: isDark ? Colors.white70 : Colors.black87), + const SizedBox(width: 5), + Text( + label, + style: TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w700, + color: isDark ? Colors.white70 : Colors.black87, + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/core/providers/app_update_provider.dart b/lib/core/providers/app_update_provider.dart new file mode 100644 index 0000000..d32c99b --- /dev/null +++ b/lib/core/providers/app_update_provider.dart @@ -0,0 +1,173 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; + +import '../utils/sdk_version.dart'; +import 'app_version_provider.dart'; + +/// Latest published release of the DevConnect Manage Tool desktop app +/// itself (not the SDKs the user-facing tip covers). Fetched on +/// construction from GitHub Releases, then auto-refreshed every +/// [_ttl]. +/// +/// The currently-running version is sourced from [appVersionProvider] +/// (which reads `PackageInfo.fromPlatform()` — the binary's own +/// Info.plist, set from `pubspec.yaml` at build time). No Dart-side +/// version constants to drift. +class AppRelease { + final String version; + final String htmlUrl; + final String? notes; + + const AppRelease({ + required this.version, + required this.htmlUrl, + this.notes, + }); +} + +class AppReleaseState { + /// Version of the running app, sourced from [appVersionProvider]. + /// Null until the platform channel has responded. + final String? currentVersion; + + final AppRelease? release; + final DateTime? fetchedAt; + final String? error; + + const AppReleaseState({ + this.currentVersion, + this.release, + this.fetchedAt, + this.error, + }); + + static const empty = AppReleaseState(); + + /// True when we have both the running version and a published + /// release, and the running version is older. + bool get hasUpdate { + final r = release; + final cur = currentVersion; + if (r == null || cur == null) return false; + return compareSdkVersions(cur, r.version) < 0; + } +} + +/// Unoauthenticated GitHub REST endpoint for the most recent release. +/// 60 req/hr limit per IP — desktop app caches, so a single cold +/// start is one call. +const _releasesUrl = + 'https://api.github.com/repos/ridelinktechs/devconnect-manage-kit/releases/latest'; + +final appUpdateProvider = + StateNotifierProvider((ref) { + final notifier = AppUpdateNotifier(ref); + ref.onDispose(notifier._onDispose); + return notifier; +}); + +class AppUpdateNotifier extends StateNotifier { + static const _ttl = Duration(minutes: 30); + static const _timeout = Duration(seconds: 5); + + final Ref _ref; + Timer? _refresh; + + AppUpdateNotifier(this._ref) : super(AppReleaseState.empty) { + _refresh = Timer.periodic(_ttl, (_) => _refreshNow()); + // ignore: discarded_futures + _bootstrap(); + } + + /// Manually re-fetch. UI surfaces this as a "Retry" affordance. + Future refresh() => _refreshNow(); + + /// One-shot init: load the running version, then kick off the + /// GitHub fetch. Order matters — `hasUpdate` needs both pieces. + Future _bootstrap() async { + final cur = await _ref.read(appVersionProvider.future); + if (!mounted) return; + state = AppReleaseState(currentVersion: cur); + await _refreshNow(); + } + + Future _refreshNow() async { + // Make sure we have the running version before checking + // `hasUpdate`. Subsequent fetches after the first will already + // have it cached in state. + final cur = state.currentVersion ?? + await _ref.read(appVersionProvider.future); + if (!mounted) return; + + try { + final resp = await http + .get( + Uri.parse(_releasesUrl), + headers: const { + 'Accept': 'application/vnd.github+json', + }, + ) + .timeout(_timeout); + if (!mounted) return; + if (resp.statusCode != 200) { + state = AppReleaseState( + currentVersion: cur, + fetchedAt: DateTime.now(), + error: 'HTTP ${resp.statusCode}', + ); + return; + } + final json = jsonDecode(resp.body); + if (json is! Map) { + state = AppReleaseState( + currentVersion: cur, + fetchedAt: DateTime.now(), + error: 'unexpected payload', + ); + return; + } + final tag = json['tag_name']; + final url = json['html_url']; + if (tag is! String || url is! String) { + state = AppReleaseState( + currentVersion: cur, + fetchedAt: DateTime.now(), + error: 'missing tag_name / html_url', + ); + return; + } + // Strip leading "v" if the tag uses it (e.g. "v1.0.4" -> "1.0.4"). + final version = tag.startsWith('v') ? tag.substring(1) : tag; + state = AppReleaseState( + currentVersion: cur, + release: AppRelease( + version: version, + htmlUrl: url, + notes: json['body'] is String ? json['body'] as String : null, + ), + fetchedAt: DateTime.now(), + ); + } on TimeoutException { + if (!mounted) return; + state = AppReleaseState( + currentVersion: cur, + fetchedAt: DateTime.now(), + error: 'timeout', + ); + } catch (e) { + if (!mounted) return; + state = AppReleaseState( + currentVersion: cur, + fetchedAt: DateTime.now(), + error: e.toString(), + ); + } + } + + void _onDispose() { + _refresh?.cancel(); + } +} \ No newline at end of file diff --git a/lib/core/providers/app_version_provider.dart b/lib/core/providers/app_version_provider.dart new file mode 100644 index 0000000..9738dcf --- /dev/null +++ b/lib/core/providers/app_version_provider.dart @@ -0,0 +1,18 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +/// Single source of truth for the desktop app's own version. +/// +/// Reads from `PackageInfo.fromPlatform()` — that returns the version +/// baked into the compiled binary's Info.plist (macOS), which Flutter +/// sets from `pubspec.yaml` during `flutter build`. So bumping +/// `pubspec.yaml` is the only thing required to "ship" a new version; +/// no Dart constants to update. +/// +/// Returns `AsyncValue` so callers can render a loading state +/// while the platform channel roundtrip is in flight. Resolved value +/// is cached by Riverpod for the lifetime of the provider scope. +final appVersionProvider = FutureProvider((_) async { + final info = await PackageInfo.fromPlatform(); + return info.version; +}); \ No newline at end of file diff --git a/lib/core/routes/app_shell.dart b/lib/core/routes/app_shell.dart index f0e1312..cb50d9d 100644 --- a/lib/core/routes/app_shell.dart +++ b/lib/core/routes/app_shell.dart @@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:window_manager/window_manager.dart'; +import '../../components/feedback/app_update_pill.dart'; import '../../components/feedback/lib_update_tips.dart'; import '../../components/layout/device_bottom_bar.dart'; import '../../components/layout/sidebar.dart'; @@ -114,6 +115,17 @@ class _AppShellState extends ConsumerState { }, ), ), + // ── App version pill (anchored to title bar, top-LEFT) ── + // macOS traffic lights occupy the leftmost ~78px; we + // start the pill at `left: 84` to stay clear of them + // and the drag area. On Windows / Linux the pill starts + // at the very edge since there's no system chrome to + // dodge. + Positioned( + top: Platform.isMacOS ? 20 : 4, + left: Platform.isMacOS ? 84 : 8, + child: const AppUpdatePill(), + ), // ── Update tips pill (anchored to OS title bar, top-right) ── // Sits in the title-bar strip so it stays above every // page's toolbar and never overlaps page content. diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 7fa7464..1ef9997 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,11 +6,15 @@ import FlutterMacOS import Foundation import file_selector_macos +import package_info_plus import screen_retriever_macos +import url_launcher_macos import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/pubspec.yaml b/pubspec.yaml index f5895c7..df572f7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,8 @@ dependencies: file_selector_platform_interface: ^2.7.0 file_selector: ^1.1.0 http: ^1.2.0 + url_launcher: ^6.3.1 + package_info_plus: ^8.1.0 dev_dependencies: flutter_test: sdk: flutter diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index e66cab6..d1a47a9 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -15,6 +16,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FileSelectorWindows")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); WindowManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 9dc3b2c..3961781 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows screen_retriever_windows + url_launcher_windows window_manager ) From a5b930b2d5d026ab3b4f492a86271136f352a918 Mon Sep 17 00:00:00 2001 From: phibvcfc Date: Sun, 12 Jul 2026 02:46:24 +0700 Subject: [PATCH 3/3] refactor: improve robustness of version comparisons, network error handling, and state management in update providers --- lib/components/feedback/app_update_pill.dart | 6 ++- lib/components/feedback/lib_update_tips.dart | 4 +- lib/core/providers/app_update_provider.dart | 36 +++++++++++-- lib/core/providers/sdk_versions_provider.dart | 51 +++++++++++++------ lib/core/utils/sdk_version.dart | 22 ++++++-- 5 files changed, 95 insertions(+), 24 deletions(-) diff --git a/lib/components/feedback/app_update_pill.dart b/lib/components/feedback/app_update_pill.dart index 51b2084..83031cf 100644 --- a/lib/components/feedback/app_update_pill.dart +++ b/lib/components/feedback/app_update_pill.dart @@ -110,7 +110,11 @@ class _AppUpdatePillState extends ConsumerState { Future _openRelease(String? url) async { final target = url ?? _releasePageFallback; - final uri = Uri.parse(target); + // `Uri.tryParse` returns null on malformed input instead of + // throwing — safer than `Uri.parse` here since the URL comes + // from a third-party API response. + final uri = Uri.tryParse(target); + if (uri == null) return; // `externalApplication` asks the OS to open in the default browser // (Safari on macOS). Falls back to in-app webview if no handler. final ok = await launchUrl(uri, mode: LaunchMode.externalApplication); diff --git a/lib/components/feedback/lib_update_tips.dart b/lib/components/feedback/lib_update_tips.dart index 68074b9..7a5dd1a 100644 --- a/lib/components/feedback/lib_update_tips.dart +++ b/lib/components/feedback/lib_update_tips.dart @@ -352,8 +352,10 @@ class _SdkRowState extends State<_SdkRow> // flash an "Update" badge off a null/empty fetch. final hasUpdate = latest != null && compareSdkVersions(e.version, latest) < 0; + // Semantic equality — `"1.2.0"` and `"1.2"` should both register + // as "up to date" rather than failing string match. final isUpToDate = - latest != null && !hasUpdate && e.version == latest; + latest != null && compareSdkVersions(e.version, latest) == 0; String platformLabel(_SdkPlatform p) { switch (p) { diff --git a/lib/core/providers/app_update_provider.dart b/lib/core/providers/app_update_provider.dart index d32c99b..0cb672c 100644 --- a/lib/core/providers/app_update_provider.dart +++ b/lib/core/providers/app_update_provider.dart @@ -88,7 +88,18 @@ class AppUpdateNotifier extends StateNotifier { /// One-shot init: load the running version, then kick off the /// GitHub fetch. Order matters — `hasUpdate` needs both pieces. Future _bootstrap() async { - final cur = await _ref.read(appVersionProvider.future); + // PackageInfo.fromPlatform() can throw on rare platform-channel + // failures. Without this try/catch the exception bubbles out of + // the StateNotifier constructor (which ignores it) AND leaves the + // GitHub fetch never started. + String? cur; + try { + cur = await _ref.read(appVersionProvider.future); + } catch (e) { + if (!mounted) return; + state = AppReleaseState(error: 'package_info: $e'); + return; + } if (!mounted) return; state = AppReleaseState(currentVersion: cur); await _refreshNow(); @@ -98,8 +109,19 @@ class AppUpdateNotifier extends StateNotifier { // Make sure we have the running version before checking // `hasUpdate`. Subsequent fetches after the first will already // have it cached in state. - final cur = state.currentVersion ?? - await _ref.read(appVersionProvider.future); + String? cur; + try { + cur = state.currentVersion ?? await _ref.read(appVersionProvider.future); + } catch (e) { + if (!mounted) return; + state = AppReleaseState( + release: state.release, + currentVersion: state.currentVersion, + fetchedAt: DateTime.now(), + error: 'package_info: $e', + ); + return; + } if (!mounted) return; try { @@ -113,8 +135,12 @@ class AppUpdateNotifier extends StateNotifier { .timeout(_timeout); if (!mounted) return; if (resp.statusCode != 200) { + // Preserve the last-known release so a transient 5xx / 403 + // doesn't suddenly flip the UI from "Update available" back + // to a stale "loading" state. state = AppReleaseState( currentVersion: cur, + release: state.release, fetchedAt: DateTime.now(), error: 'HTTP ${resp.statusCode}', ); @@ -124,6 +150,7 @@ class AppUpdateNotifier extends StateNotifier { if (json is! Map) { state = AppReleaseState( currentVersion: cur, + release: state.release, fetchedAt: DateTime.now(), error: 'unexpected payload', ); @@ -134,6 +161,7 @@ class AppUpdateNotifier extends StateNotifier { if (tag is! String || url is! String) { state = AppReleaseState( currentVersion: cur, + release: state.release, fetchedAt: DateTime.now(), error: 'missing tag_name / html_url', ); @@ -154,6 +182,7 @@ class AppUpdateNotifier extends StateNotifier { if (!mounted) return; state = AppReleaseState( currentVersion: cur, + release: state.release, fetchedAt: DateTime.now(), error: 'timeout', ); @@ -161,6 +190,7 @@ class AppUpdateNotifier extends StateNotifier { if (!mounted) return; state = AppReleaseState( currentVersion: cur, + release: state.release, fetchedAt: DateTime.now(), error: e.toString(), ); diff --git a/lib/core/providers/sdk_versions_provider.dart b/lib/core/providers/sdk_versions_provider.dart index b96dfb5..ffba5b9 100644 --- a/lib/core/providers/sdk_versions_provider.dart +++ b/lib/core/providers/sdk_versions_provider.dart @@ -83,6 +83,10 @@ class SdkVersionsNotifier extends StateNotifier { 'https://pub.dev/api/packages/devconnect_manage_kit'; Timer? _refresh; + // Guard against concurrent fetches — Rapid Retry clicks would + // otherwise stack parallel HTTP requests and the second one's + // state assignment would race the first. + bool _inFlight = false; SdkVersionsNotifier() : super(SdkLatestVersions.empty) { // Kick off the first fetch asynchronously so the StateNotifier @@ -97,23 +101,40 @@ class SdkVersionsNotifier extends StateNotifier { Future refresh() => _refreshNow(); Future _refreshNow() async { - // Run both fetches in parallel. Each is wrapped in its own - // try/catch so a failure on one platform doesn't poison the other. - final results = await Future.wait([_fetchNpm(), _fetchPub()]); - // Guard against "use after dispose": the widget tree holding us - // could tear down (user closes the panel mid-fetch). Without this - // check, the `state =` below would throw "Bad state: Cannot use a - // StateNotifier after its dispose()". - if (!mounted) return; - final npm = results[0]; // _fetchNpm() -> reactNative - final pub = results[1]; // _fetchPub() -> flutter + if (_inFlight) return; + _inFlight = true; + // Reset the per-platform error flags so the UI transitions from + // "Live check unavailable" back to the spinner immediately on + // retry, instead of waiting up to 5s for the new fetch to land. + // Keep the previous values so the pill doesn't briefly read + // "missing" — once the fetch finishes the new values overwrite. state = SdkLatestVersions( - flutter: pub.value, - reactNative: npm.value, - fetchedAt: DateTime.now(), - flutterError: pub.error, - reactNativeError: npm.error, + flutter: state.flutter, + reactNative: state.reactNative, + flutterError: null, + reactNativeError: null, ); + try { + // Run both fetches in parallel. Each is wrapped in its own + // try/catch so a failure on one platform doesn't poison the other. + final results = await Future.wait([_fetchNpm(), _fetchPub()]); + // Guard against "use after dispose": the widget tree holding us + // could tear down (user closes the panel mid-fetch). Without this + // check, the `state =` below would throw "Bad state: Cannot use a + // StateNotifier after its dispose()". + if (!mounted) return; + final npm = results[0]; // _fetchNpm() -> reactNative + final pub = results[1]; // _fetchPub() -> flutter + state = SdkLatestVersions( + flutter: pub.value, + reactNative: npm.value, + fetchedAt: DateTime.now(), + flutterError: pub.error, + reactNativeError: npm.error, + ); + } finally { + _inFlight = false; + } } Future<_Result> _fetchNpm() async { diff --git a/lib/core/utils/sdk_version.dart b/lib/core/utils/sdk_version.dart index 88bd49d..c095526 100644 --- a/lib/core/utils/sdk_version.dart +++ b/lib/core/utils/sdk_version.dart @@ -2,8 +2,11 @@ /// /// Returns negative if [a] < [b], zero if equal, positive if [a] > [b]. /// Missing components default to `0` (so `1.2` is treated as `1.2.0`). -/// Pre-release tags (`1.2.3-beta.1`) are ignored — fine for the SDKs we -/// ship, where every release is a stable `MAJOR.MINOR.PATCH`. +/// Pre-release tags are stripped from each component before parsing: +/// `1.2.3-beta.1` and `1.2.3` both reduce to `1.2.3` for comparison +/// purposes. Pre-release ordering (alpha vs beta vs rc) is NOT +/// respected — fine for the SDKs we ship, where every release is a +/// stable `MAJOR.MINOR.PATCH`. /// /// Non-numeric components (e.g. `1.x.0`) are treated as `0` instead of /// throwing — we don't want a malformed registry payload to crash the @@ -13,9 +16,20 @@ int compareSdkVersions(String a, String b) { final pb = b.split('.'); final length = pa.length > pb.length ? pa.length : pb.length; for (var i = 0; i < length; i++) { - final ai = i < pa.length ? int.tryParse(pa[i]) ?? 0 : 0; - final bi = i < pb.length ? int.tryParse(pb[i]) ?? 0 : 0; + final ai = i < pa.length ? _componentInt(pa[i]) : 0; + final bi = i < pb.length ? _componentInt(pb[i]) : 0; if (ai != bi) return ai - bi; } return 0; +} + +/// Parses the leading numeric portion of a single version component. +/// `3-beta.1` → 3, `12rc2` → 12, `` → 0, `x` → 0. Without this, +/// `int.tryParse('3-beta')` returns null and we'd silently treat +/// pre-release builds as the same version as the stable release — +/// e.g. `1.0.0-beta` would compare equal to `1.0.0` instead of +/// being treated as the same major/minor/patch. +int _componentInt(String s) { + final match = RegExp(r'^\d+').firstMatch(s); + return match != null ? int.parse(match.group(0)!) : 0; } \ No newline at end of file