Files
minstrel/flutter_client/lib/update/update_banner.dart
T
bvandeusen 835592f073 refactor(ui): Lucide migration unit 2 — Icons.* -> LucideIcons.* sweep (#60)
Mechanical sweep across 30 files: every Material Icons.* replaced with
the signed-off Lucide equivalent + a flutter_lucide import per file.
Zero Material Icons.* remain in lib/; no unused imports.

Judgment-call mappings: album->disc_3, library_music->library_big,
playlist_play->list_video, graphic_eq->audio_lines,
system_update->download, restore->archive_restore,
download_done->circle_check_big.

track_actions_sheet like menu row: collapsed `liked ? favorite :
favorite_border` to a single LucideIcons.heart (the row's Like/Unlike
text label conveys state). Icon-only LikeButton + the notification keep
the filled-vs-outline shape per the design decision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:43:05 -04:00

147 lines
5.2 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../theme/theme_extension.dart';
import 'client_update_provider.dart';
import 'update_info.dart';
/// Soft banner mounted at the top of the shell. Renders nothing when
/// no update is available or the user has dismissed this version's
/// banner. Tapping Install downloads the APK and fires the system
/// install intent (Android only).
class UpdateBanner extends ConsumerStatefulWidget {
const UpdateBanner({super.key});
@override
ConsumerState<UpdateBanner> createState() => _UpdateBannerState();
}
enum _Stage { idle, downloading, error }
class _UpdateBannerState extends ConsumerState<UpdateBanner> {
_Stage _stage = _Stage.idle;
double _progress = 0;
String? _error;
Future<void> _onInstall(UpdateInfo info) async {
setState(() {
_stage = _Stage.downloading;
_progress = 0;
_error = null;
});
try {
final installer = await ref.read(updateInstallerProvider.future);
final path = await installer.download(
info.apkUrl,
onProgress: (p) => setState(() => _progress = p),
);
await installer.install(path);
// Stage stays 'downloading' — Android system install dialog has
// taken over. If user cancels, the banner is still here.
} catch (e) {
if (!mounted) return;
setState(() {
_stage = _Stage.error;
_error = '$e';
});
}
}
void _onDismiss(UpdateInfo info) {
ref.read(dismissUpdateProvider)(info.version);
}
@override
Widget build(BuildContext context) {
final info = ref.watch(shouldShowUpdateBannerProvider);
if (info == null) return const SizedBox.shrink();
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Material(
color: fs.iron,
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 4, 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(LucideIcons.download, color: fs.parchment, size: 16),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_stage == _Stage.error
? 'Update failed'
: 'Update Minstrel · ${info.version} available',
style: TextStyle(color: fs.parchment, fontSize: 12),
overflow: TextOverflow.ellipsis,
),
if (_stage == _Stage.downloading) ...[
const SizedBox(height: 2),
LinearProgressIndicator(
value: _progress > 0 ? _progress : null,
minHeight: 2,
backgroundColor: fs.obsidian,
valueColor: AlwaysStoppedAnimation(fs.accent),
),
],
if (_stage == _Stage.error && _error != null) ...[
const SizedBox(height: 1),
Text(
_error!,
style: TextStyle(color: fs.ash, fontSize: 11),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
if (_stage == _Stage.idle)
TextButton(
onPressed: () => _onInstall(info),
style: TextButton.styleFrom(
foregroundColor: fs.accent,
minimumSize: const Size(56, 28),
padding: const EdgeInsets.symmetric(horizontal: 8),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
child: const Text('Install', style: TextStyle(fontSize: 13)),
),
if (_stage == _Stage.error)
TextButton(
onPressed: () => _onInstall(info),
style: TextButton.styleFrom(
foregroundColor: fs.accent,
minimumSize: const Size(56, 28),
padding: const EdgeInsets.symmetric(horizontal: 8),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
child: const Text('Retry', style: TextStyle(fontSize: 13)),
),
SizedBox(
width: 32,
height: 32,
child: IconButton(
tooltip: 'Dismiss',
padding: EdgeInsets.zero,
iconSize: 16,
icon: Icon(LucideIcons.x, color: fs.ash),
onPressed: () => _onDismiss(info),
),
),
],
),
),
),
);
}
}