33b11a3b3d
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) <noreply@anthropic.com>
228 lines
7.2 KiB
Dart
228 lines
7.2 KiB
Dart
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<PackageInfo>((_) {
|
|
return PackageInfo.fromPlatform();
|
|
});
|
|
|
|
class AboutSection extends ConsumerStatefulWidget {
|
|
const AboutSection({super.key});
|
|
|
|
@override
|
|
ConsumerState<AboutSection> createState() => _AboutSectionState();
|
|
}
|
|
|
|
enum _InstallStage { idle, downloading, error }
|
|
|
|
class _AboutSectionState extends ConsumerState<AboutSection> {
|
|
DateTime? _lastChecked;
|
|
bool _checking = false;
|
|
|
|
_InstallStage _installStage = _InstallStage.idle;
|
|
double _installProgress = 0;
|
|
String? _installError;
|
|
|
|
Future<void> _checkNow() async {
|
|
setState(() => _checking = true);
|
|
ref.invalidate(clientUpdateProvider);
|
|
try {
|
|
await ref.read(clientUpdateProvider.future);
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_checking = false;
|
|
_lastChecked = DateTime.now();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _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<FabledSwordTheme>()!;
|
|
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<UpdateInfo?> 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<FabledSwordTheme>()!;
|
|
return Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: fs.parchment,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
);
|
|
}
|
|
}
|