From 70a327919266df1ca2c354dd2af7d26b7b10bae6 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Fri, 17 Apr 2026 12:38:48 -0400 Subject: [PATCH] feat: background update download, snackbar prompt, chat image rendering Update provider: auto-downloads APK in background after finding a newer version, prompts via snackbar only when ready, cleans up old APKs on startup. Replaces modal dialog with dismissible snackbar. Chat bubble: resolve relative image URLs (/api/images/{id}) against server base URL with auth cookies so search_images results render on the phone app. Co-Authored-By: Claude Opus 4.6 --- lib/app.dart | 77 +++----------- lib/providers/update_provider.dart | 118 ++++++++++++++++------ lib/screens/settings/settings_screen.dart | 8 +- lib/widgets/chat_message_bubble.dart | 85 +++++++++++++++- 4 files changed, 187 insertions(+), 101 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 763ac89..56f4010 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -205,6 +205,8 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { super.initState(); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) { + // Clean up any leftover APKs from previous update cycles. + ref.read(updateProvider.notifier).cleanup(); // Silent update check — only if we haven't already checked this session. final repoUrl = ref.read(forgejoRepoUrlProvider); if (repoUrl != null && repoUrl.isNotEmpty) { @@ -318,66 +320,15 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { ); } - void _showUpdateDialog(UpdateState update) { - showDialog( - context: context, - builder: (dialogContext) => Consumer( - builder: (context, ref, _) { - final state = ref.watch(updateProvider); - final isDownloading = state.status == UpdateStatus.downloading; - return AlertDialog( - title: const Text('Update available'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Version ${state.latestVersion ?? '?'} is ready to install.'), - if (state.currentVersion != null) - Text( - 'Installed: v${state.currentVersion}', - style: Theme.of(context).textTheme.bodySmall, - ), - if (isDownloading) ...[ - const SizedBox(height: 16), - LinearProgressIndicator( - value: state.downloadProgress > 0 - ? state.downloadProgress - : null, - ), - const SizedBox(height: 4), - Text( - 'Downloading… ' - '${(state.downloadProgress * 100).toStringAsFixed(0)}%', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - if (state.status == UpdateStatus.error && - state.errorMessage != null) ...[ - const SizedBox(height: 12), - Text( - state.errorMessage!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.error, - ), - ), - ], - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: const Text('Later'), - ), - if (!isDownloading && state.downloadUrl != null) - FilledButton( - onPressed: () => ref - .read(updateProvider.notifier) - .downloadAndInstall(), - child: const Text('Download & Install'), - ), - ], - ); - }, + void _showUpdateSnackbar(UpdateState update) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('v${update.latestVersion} ready to install'), + duration: const Duration(seconds: 6), + action: SnackBarAction( + label: 'Install', + onPressed: () => ref.read(updateProvider.notifier).install(), + ), ), ); } @@ -386,10 +337,10 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { Widget build(BuildContext context) { // Show update dialog once when a new version is detected. ref.listen(updateProvider, (prev, next) { - if (next.status == UpdateStatus.available && - prev?.status != UpdateStatus.available) { + if (next.status == UpdateStatus.readyToInstall && + prev?.status != UpdateStatus.readyToInstall) { WidgetsBinding.instance - .addPostFrameCallback((_) => _showUpdateDialog(next)); + .addPostFrameCallback((_) => _showUpdateSnackbar(next)); } }); final location = GoRouterState.of(context).matchedLocation; diff --git a/lib/providers/update_provider.dart b/lib/providers/update_provider.dart index b5aac9c..dcd6b04 100644 --- a/lib/providers/update_provider.dart +++ b/lib/providers/update_provider.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:open_file/open_file.dart'; @@ -5,7 +7,14 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; -enum UpdateStatus { idle, checking, available, downloading, upToDate, error } +enum UpdateStatus { + idle, + checking, + downloading, + readyToInstall, + upToDate, + error, +} class UpdateState { final UpdateStatus status; @@ -14,6 +23,7 @@ class UpdateState { final String? downloadUrl; final double downloadProgress; final String? errorMessage; + final String? apkPath; const UpdateState({ this.status = UpdateStatus.idle, @@ -22,6 +32,7 @@ class UpdateState { this.downloadUrl, this.downloadProgress = 0.0, this.errorMessage, + this.apkPath, }); UpdateState copyWith({ @@ -31,6 +42,7 @@ class UpdateState { String? downloadUrl, double? downloadProgress, String? errorMessage, + String? apkPath, }) => UpdateState( status: status ?? this.status, @@ -39,6 +51,7 @@ class UpdateState { downloadUrl: downloadUrl ?? this.downloadUrl, downloadProgress: downloadProgress ?? this.downloadProgress, errorMessage: errorMessage ?? this.errorMessage, + apkPath: apkPath ?? this.apkPath, ); } @@ -46,20 +59,15 @@ class UpdateNotifier extends Notifier { @override UpdateState build() => const UpdateState(); - /// [repoUrl] is the Forgejo repo page URL, e.g. - /// "https://git.example.com/user/fabled_app" Future check(String repoUrl) async { state = state.copyWith(status: UpdateStatus.checking); try { final packageInfo = await PackageInfo.fromPlatform(); - // Combine versionName + buildNumber to match the YY.MM.DD.N tag format. final currentVersion = '${packageInfo.version}.${packageInfo.buildNumber}'; - // Parse repo URL → Forgejo API endpoint final uri = Uri.parse(repoUrl); - final parts = - uri.pathSegments.where((s) => s.isNotEmpty).toList(); + final parts = uri.pathSegments.where((s) => s.isNotEmpty).toList(); if (parts.length < 2) throw 'Invalid repository URL (need /owner/repo)'; final apiUrl = '${uri.scheme}://${uri.authority}/api/v1/repos/${parts[0]}/${parts[1]}/releases/latest'; @@ -70,20 +78,20 @@ class UpdateNotifier extends Notifier { tagName.startsWith('v') ? tagName.substring(1) : tagName; if (_isNewer(latestVersion, currentVersion)) { - final assets = - (response.data['assets'] as List? ?? []) - .cast>(); + final assets = (response.data['assets'] as List? ?? []) + .cast>(); final apk = assets.firstWhere( (a) => (a['name'] as String? ?? '').endsWith('.apk'), orElse: () => {}, ); if (apk.isNotEmpty) { + final downloadUrl = apk['browser_download_url'] as String?; state = state.copyWith( - status: UpdateStatus.available, currentVersion: currentVersion, latestVersion: latestVersion, - downloadUrl: apk['browser_download_url'] as String?, + downloadUrl: downloadUrl, ); + await _downloadInBackground(); return; } } @@ -101,11 +109,52 @@ class UpdateNotifier extends Notifier { } } - Future downloadAndInstall() async { + Future _downloadInBackground() async { if (state.downloadUrl == null) return; + state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0); + + try { + final dir = await _apkDir(); + await _cleanupApks(dir); + + final path = '${dir.path}/fabled_${state.latestVersion}.apk'; + + await Dio().download( + state.downloadUrl!, + path, + onReceiveProgress: (received, total) { + if (total > 0) { + state = state.copyWith(downloadProgress: received / total); + } + }, + ); + + final file = File(path); + if (!await file.exists() || await file.length() == 0) { + state = state.copyWith( + status: UpdateStatus.error, + errorMessage: 'Download failed — file is empty', + ); + return; + } + + state = state.copyWith( + status: UpdateStatus.readyToInstall, + apkPath: path, + downloadProgress: 1.0, + ); + } catch (e) { + state = state.copyWith( + status: UpdateStatus.error, + errorMessage: e.toString(), + ); + } + } + + Future install() async { + final path = state.apkPath; + if (path == null) return; - // Android 8+ requires explicit per-app "Install unknown apps" approval - // beyond the manifest declaration. Check and redirect to Settings if needed. final installPermission = await Permission.requestInstallPackages.status; if (!installPermission.isGranted) { final result = await Permission.requestInstallPackages.request(); @@ -119,30 +168,13 @@ class UpdateNotifier extends Notifier { } } - state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0); - try { - final dir = await getExternalStorageDirectory() ?? - await getTemporaryDirectory(); - final path = '${dir.path}/fabled_update.apk'; - - await Dio().download( - state.downloadUrl!, - path, - onReceiveProgress: (received, total) { - if (total > 0) { - state = state.copyWith(downloadProgress: received / total); - } - }, - ); - final result = await OpenFile.open( path, type: 'application/vnd.android.package-archive', ); if (result.type == ResultType.done) { - // Installer launched — reset to idle so the dialog closes naturally. state = const UpdateState(); } else { state = state.copyWith( @@ -158,8 +190,30 @@ class UpdateNotifier extends Notifier { } } + /// Remove any previously cached APKs. + Future cleanup() async { + final dir = await _apkDir(); + await _cleanupApks(dir); + } + void dismiss() => state = const UpdateState(); + Future _apkDir() async { + return await getExternalStorageDirectory() ?? + await getTemporaryDirectory(); + } + + Future _cleanupApks(Directory dir) async { + try { + final entries = dir.listSync(); + for (final entry in entries) { + if (entry is File && entry.path.endsWith('.apk')) { + await entry.delete(); + } + } + } catch (_) {} + } + bool _isNewer(String latest, String current) { try { final l = latest.split('.').map(int.parse).toList(); diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 652886f..0dcf546 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -92,7 +92,7 @@ class SettingsScreen extends ConsumerWidget { child: const Text('Check'), ), ), - if (update.status == UpdateStatus.available || + if (update.status == UpdateStatus.readyToInstall || update.status == UpdateStatus.downloading) _UpdateTile(update: update), if (update.status == UpdateStatus.error) @@ -131,7 +131,7 @@ class SettingsScreen extends ConsumerWidget { if (update.status == UpdateStatus.upToDate) { return Text('v$current — up to date'); } - if (update.status == UpdateStatus.available || + if (update.status == UpdateStatus.readyToInstall || update.status == UpdateStatus.downloading) { return Text('v$current installed'); } @@ -202,12 +202,12 @@ class _UpdateTile extends ConsumerWidget { '${(update.downloadProgress * 100).toStringAsFixed(0)}%'), ], ) - : const Text('Tap to download and install'), + : const Text('Ready to install'), trailing: isDownloading ? null : FilledButton( onPressed: () => - ref.read(updateProvider.notifier).downloadAndInstall(), + ref.read(updateProvider.notifier).install(), child: const Text('Install'), ), ); diff --git a/lib/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart index 64c6266..bf000b0 100644 --- a/lib/widgets/chat_message_bubble.dart +++ b/lib/widgets/chat_message_bubble.dart @@ -1,12 +1,17 @@ import 'dart:math' show min; +import 'dart:typed_data'; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/models/message.dart'; +import '../providers/api_client_provider.dart'; +import '../providers/settings_provider.dart'; import 'tool_call_chip.dart'; -class ChatMessageBubble extends StatelessWidget { +class ChatMessageBubble extends ConsumerWidget { final Message message; final String streamingStatus; const ChatMessageBubble({ @@ -16,9 +21,11 @@ class ChatMessageBubble extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final isUser = message.role == MessageRole.user; final scheme = Theme.of(context).colorScheme; + final serverUrl = ref.watch(serverUrlProvider) ?? ''; + final dio = ref.watch(dioProvider); final isGenerating = message.status == 'generating'; final toolCalls = message.toolCalls ?? const []; @@ -116,6 +123,14 @@ class ChatMessageBubble extends StatelessWidget { if (message.content.isNotEmpty) MarkdownBody( data: message.content, + imageBuilder: (uri, title, alt) { + return _AuthImage( + uri: uri, + alt: alt, + serverUrl: serverUrl, + dio: dio, + ); + }, styleSheet: MarkdownStyleSheet( p: TextStyle( color: isUser @@ -161,3 +176,69 @@ class ChatMessageBubble extends StatelessWidget { ); } } + +class _AuthImage extends StatefulWidget { + final Uri uri; + final String? alt; + final String serverUrl; + final Dio dio; + + const _AuthImage({ + required this.uri, + this.alt, + required this.serverUrl, + required this.dio, + }); + + @override + State<_AuthImage> createState() => _AuthImageState(); +} + +class _AuthImageState extends State<_AuthImage> { + late Future _future; + + @override + void initState() { + super.initState(); + _future = _fetchImage(); + } + + Future _fetchImage() async { + var url = widget.uri.toString(); + if (url.startsWith('/')) { + url = '${widget.serverUrl}$url'; + } + final response = await widget.dio.get>( + url, + options: Options(responseType: ResponseType.bytes), + ); + return Uint8List.fromList(response.data!); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const SizedBox( + height: 100, + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ); + } + if (snapshot.hasError || !snapshot.hasData) { + return Text(widget.alt ?? 'Image failed to load'); + } + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.memory( + snapshot.data!, + fit: BoxFit.contain, + errorBuilder: (_, _, _) => + Text(widget.alt ?? 'Image failed to load'), + ), + ); + }, + ); + } +}