From 33b11a3b3dd6565343a46d7950567dcff90691c2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 14 May 2026 22:26:22 -0400 Subject: [PATCH] feat(settings): About section with version + force update check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an About card to Settings that shows the installed version (version+build from PackageInfo), the latest known version from clientUpdateProvider, and a "Check for updates" button that invalidates the provider to force a fresh poll. When an update is available, surfaces an Install CTA that reuses the same installer flow as the top banner. The existing banner (shouldShowUpdateBannerProvider) is unaffected — it gates on per-version dismissal, while the About section always reflects the current provider state regardless of dismissal. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/settings/about_section.dart | 227 ++++++++++++++++++ .../lib/settings/settings_screen.dart | 3 + 2 files changed, 230 insertions(+) create mode 100644 flutter_client/lib/settings/about_section.dart diff --git a/flutter_client/lib/settings/about_section.dart b/flutter_client/lib/settings/about_section.dart new file mode 100644 index 00000000..efd5ada1 --- /dev/null +++ b/flutter_client/lib/settings/about_section.dart @@ -0,0 +1,227 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../theme/theme_extension.dart'; +import '../update/client_update_provider.dart'; +import '../update/update_info.dart'; + +final _packageInfoProvider = FutureProvider((_) { + return PackageInfo.fromPlatform(); +}); + +class AboutSection extends ConsumerStatefulWidget { + const AboutSection({super.key}); + + @override + ConsumerState createState() => _AboutSectionState(); +} + +enum _InstallStage { idle, downloading, error } + +class _AboutSectionState extends ConsumerState { + DateTime? _lastChecked; + bool _checking = false; + + _InstallStage _installStage = _InstallStage.idle; + double _installProgress = 0; + String? _installError; + + Future _checkNow() async { + setState(() => _checking = true); + ref.invalidate(clientUpdateProvider); + try { + await ref.read(clientUpdateProvider.future); + } finally { + if (mounted) { + setState(() { + _checking = false; + _lastChecked = DateTime.now(); + }); + } + } + } + + Future _install(UpdateInfo info) async { + setState(() { + _installStage = _InstallStage.downloading; + _installProgress = 0; + _installError = null; + }); + try { + final installer = await ref.read(updateInstallerProvider.future); + final path = await installer.download( + info.apkUrl, + onProgress: (p) { + if (mounted) setState(() => _installProgress = p); + }, + ); + await installer.install(path); + } catch (e) { + if (!mounted) return; + setState(() { + _installStage = _InstallStage.error; + _installError = '$e'; + }); + } + } + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + final pkg = ref.watch(_packageInfoProvider); + final update = ref.watch(clientUpdateProvider); + + final installed = pkg.value == null + ? '…' + : '${pkg.value!.version}+${pkg.value!.buildNumber}'; + + final UpdateInfo? available = update.value; + final hasUpdate = available != null; + + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 4, 16, 8), + child: _SectionHeader('About'), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), + child: Row( + children: [ + Icon(Icons.info_outline, color: fs.parchment, size: 18), + const SizedBox(width: 8), + Text('Installed version', + style: TextStyle(color: fs.parchment, fontSize: 14)), + const Spacer(), + Text(installed, + style: TextStyle( + color: fs.ash, + fontSize: 13, + fontFamily: 'JetBrainsMono')), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 4), + child: Row( + children: [ + Icon(Icons.cloud_outlined, color: fs.parchment, size: 18), + const SizedBox(width: 8), + Text('Latest version', + style: TextStyle(color: fs.parchment, fontSize: 14)), + const Spacer(), + Text( + hasUpdate ? available.version : _statusFor(update, installed), + style: TextStyle( + color: hasUpdate ? fs.accent : fs.ash, + fontSize: 13, + fontFamily: 'JetBrainsMono'), + ), + ], + ), + ), + if (_lastChecked != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), + child: Text( + 'Last checked ${_formatTime(_lastChecked!)}', + style: TextStyle(color: fs.ash, fontSize: 11), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Row( + children: [ + FilledButton.icon( + key: const Key('about_check_for_updates'), + onPressed: _checking ? null : _checkNow, + style: FilledButton.styleFrom( + backgroundColor: fs.accent, + foregroundColor: fs.parchment, + ), + icon: _checking + ? SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(fs.parchment), + ), + ) + : const Icon(Icons.refresh, size: 18), + label: Text(_checking ? 'Checking…' : 'Check for updates'), + ), + if (hasUpdate) ...[ + const SizedBox(width: 12), + FilledButton.icon( + key: const Key('about_install_update'), + onPressed: _installStage == _InstallStage.downloading + ? null + : () => _install(available), + style: FilledButton.styleFrom( + backgroundColor: fs.moss, + foregroundColor: fs.parchment, + ), + icon: _installStage == _InstallStage.downloading + ? SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + value: _installProgress > 0 ? _installProgress : null, + valueColor: AlwaysStoppedAnimation(fs.parchment), + ), + ) + : const Icon(Icons.system_update, size: 18), + label: Text( + _installStage == _InstallStage.downloading + ? 'Downloading…' + : _installStage == _InstallStage.error + ? 'Retry install' + : 'Install ${available.version}', + ), + ), + ], + ], + ), + ), + if (_installStage == _InstallStage.error && _installError != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Text(_installError!, + style: TextStyle(color: fs.error, fontSize: 12), + maxLines: 2, + overflow: TextOverflow.ellipsis), + ), + ]); + } + + String _statusFor(AsyncValue update, String installed) { + if (update.isLoading) return 'Checking…'; + if (update.hasError) return 'Check failed'; + return 'Up to date'; + } + + String _formatTime(DateTime t) { + final h = t.hour.toString().padLeft(2, '0'); + final m = t.minute.toString().padLeft(2, '0'); + return '$h:$m'; + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader(this.label); + final String label; + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Text( + label, + style: TextStyle( + color: fs.parchment, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ); + } +} diff --git a/flutter_client/lib/settings/settings_screen.dart b/flutter_client/lib/settings/settings_screen.dart index d1bdbab9..e8810062 100644 --- a/flutter_client/lib/settings/settings_screen.dart +++ b/flutter_client/lib/settings/settings_screen.dart @@ -9,6 +9,7 @@ import '../library/library_providers.dart' show dioProvider; import '../models/my_profile.dart'; import '../shared/widgets/main_app_bar_actions.dart'; import '../theme/theme_extension.dart'; +import 'about_section.dart'; import 'storage_section.dart'; import '../theme/theme_mode_provider.dart'; @@ -56,6 +57,8 @@ class SettingsScreen extends ConsumerWidget { _PasswordSection(), _Divider(), _ListenBrainzSection(), + _Divider(), + AboutSection(), _AdminSection(), SizedBox(height: 96), ],