Add self-hosted update system via Forgejo releases
The app now checks for new APK releases on a Forgejo instance and can download and install them without leaving the app. Settings: - New "Update repository URL" field (e.g. https://git.example.com/user/fabled_app) - "App version" tile with a manual "Check" button and live status display - Download progress bar and "Install" button appear when update is found Startup: - Shell runs a silent background check on first load when a repo URL is set - A dialog appears automatically if a newer release is found Provider (update_provider.dart): - UpdateNotifier: idle → checking → available → downloading → available - Hits Forgejo /api/v1/repos/{owner}/{repo}/releases/latest - Parses semver tag, finds .apk asset, downloads with progress via Dio - Opens the APK with open_file to trigger the system package installer Android: - REQUEST_INSTALL_PACKAGES permission - FileProvider configured for open_file (shares APK URI with installer) - res/xml/file_paths.xml covers external-files and cache directories Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+83
-2
@@ -9,6 +9,7 @@ import 'providers/auth_provider.dart';
|
||||
import 'providers/capture_queue_provider.dart';
|
||||
import 'providers/notes_provider.dart';
|
||||
import 'providers/settings_provider.dart';
|
||||
import 'providers/update_provider.dart';
|
||||
import 'providers/tasks_provider.dart';
|
||||
import 'screens/auth/login_screen.dart';
|
||||
import 'screens/chat/chat_screen.dart';
|
||||
@@ -127,12 +128,29 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
);
|
||||
});
|
||||
|
||||
class _Shell extends ConsumerWidget {
|
||||
class _Shell extends ConsumerStatefulWidget {
|
||||
final Widget child;
|
||||
const _Shell({required this.child});
|
||||
|
||||
@override
|
||||
ConsumerState<_Shell> createState() => _ShellState();
|
||||
}
|
||||
|
||||
class _ShellState extends ConsumerState<_Shell> {
|
||||
static const _tabs = [Routes.notes, Routes.tasks, Routes.conversations];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Silent update check on first app load.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final repoUrl = ref.read(forgejoRepoUrlProvider);
|
||||
if (repoUrl != null && repoUrl.isNotEmpty) {
|
||||
ref.read(updateProvider.notifier).check(repoUrl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
int _tabIndex(String location) {
|
||||
for (var i = 0; i < _tabs.length; i++) {
|
||||
if (location.startsWith(_tabs[i])) return i;
|
||||
@@ -140,10 +158,73 @@ class _Shell extends ConsumerWidget {
|
||||
return 0;
|
||||
}
|
||||
|
||||
void _showUpdateDialog(UpdateState update) {
|
||||
showDialog<void>(
|
||||
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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Later'),
|
||||
),
|
||||
if (!isDownloading)
|
||||
FilledButton(
|
||||
onPressed: () => ref
|
||||
.read(updateProvider.notifier)
|
||||
.downloadAndInstall(),
|
||||
child: const Text('Download & Install'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
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) {
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) => _showUpdateDialog(next));
|
||||
}
|
||||
});
|
||||
final location = GoRouterState.of(context).matchedLocation;
|
||||
final index = _tabIndex(location);
|
||||
final child = widget.child;
|
||||
// Use NavigationRail whenever the screen is wide enough — covers both
|
||||
// phone landscape and tablets in either orientation (600 dp breakpoint).
|
||||
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||
|
||||
Reference in New Issue
Block a user