This repository has been archived on 2026-06-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
FabledApp/lib/app.dart
T
bvandeusen ad3b317115 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>
2026-03-01 14:08:17 -05:00

503 lines
16 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'core/constants.dart';
import 'core/exceptions.dart';
import 'providers/api_client_provider.dart';
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';
import 'screens/chat/conversations_list_screen.dart';
import 'screens/notes/note_detail_screen.dart';
import 'screens/notes/note_edit_screen.dart';
import 'screens/notes/notes_list_screen.dart';
import 'screens/settings/settings_screen.dart';
import 'screens/setup/setup_screen.dart';
import 'screens/splash/splash_screen.dart';
import 'screens/tasks/task_edit_screen.dart';
import 'screens/tasks/tasks_list_screen.dart';
// ChangeNotifier that fires when auth or server URL changes,
// used as GoRouter.refreshListenable so the router re-evaluates redirects
// without being recreated.
class _RouterNotifier extends ChangeNotifier {
_RouterNotifier(Ref ref) {
ref.listen(authProvider, (_, _) => notifyListeners());
ref.listen(serverUrlProvider, (_, _) => notifyListeners());
}
}
final routerProvider = Provider<GoRouter>((ref) {
final notifier = _RouterNotifier(ref);
return GoRouter(
refreshListenable: notifier,
initialLocation: Routes.splash,
redirect: (context, state) {
final location = state.matchedLocation;
final serverUrl = ref.read(serverUrlProvider);
final authStatus = ref.read(authProvider);
if (serverUrl == null || serverUrl.isEmpty) {
if (location != Routes.setup) return Routes.setup;
return null;
}
if (authStatus == AuthStatus.unauthenticated) {
if (location != Routes.login && location != Routes.setup) {
return Routes.login;
}
return null;
}
return null;
},
routes: [
GoRoute(
path: Routes.splash,
builder: (_, _) => const SplashScreen(),
),
GoRoute(
path: Routes.setup,
builder: (_, _) => const SetupScreen(),
),
GoRoute(
path: Routes.login,
builder: (_, _) => const LoginScreen(),
),
GoRoute(
path: Routes.settings,
builder: (_, _) => const SettingsScreen(),
),
GoRoute(
path: Routes.noteNew,
builder: (_, _) => const NoteEditScreen(),
),
GoRoute(
path: Routes.noteDetail,
builder: (_, state) => NoteDetailScreen(
noteId: int.parse(state.pathParameters['id']!),
),
),
GoRoute(
path: Routes.noteEdit,
builder: (_, state) => NoteEditScreen(
noteId: int.parse(state.pathParameters['id']!),
),
),
GoRoute(
path: Routes.taskNew,
builder: (_, _) => const TaskEditScreen(),
),
GoRoute(
path: Routes.taskEdit,
builder: (_, state) => TaskEditScreen(
taskId: int.parse(state.pathParameters['id']!),
),
),
GoRoute(
path: Routes.chat,
builder: (_, state) => ChatScreen(
conversationId: int.parse(state.pathParameters['id']!),
),
),
ShellRoute(
builder: (context, state, child) => _Shell(child: child),
routes: [
GoRoute(
path: Routes.notes,
builder: (_, _) => const NotesListScreen(),
),
GoRoute(
path: Routes.tasks,
builder: (_, _) => const TasksListScreen(),
),
GoRoute(
path: Routes.conversations,
builder: (_, _) => const ConversationsListScreen(),
),
],
),
],
);
});
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;
}
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) {
// 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;
if (isWide) {
return Scaffold(
body: SafeArea(
child: Column(
children: [
const _QuickCaptureBar(),
Expanded(
child: Row(
children: [
NavigationRail(
selectedIndex: index,
onDestinationSelected: (i) => context.go(_tabs[i]),
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.note_outlined),
selectedIcon: Icon(Icons.note),
label: Text('Notes'),
),
NavigationRailDestination(
icon: Icon(Icons.check_box_outlined),
selectedIcon: Icon(Icons.check_box),
label: Text('Tasks'),
),
NavigationRailDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
label: Text('Chat'),
),
],
),
const VerticalDivider(width: 1),
Expanded(child: child),
],
),
),
],
),
),
);
}
return Scaffold(
body: Column(
children: [
const _QuickCaptureBar(),
Expanded(child: child),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: index,
onDestinationSelected: (i) => context.go(_tabs[i]),
destinations: const [
NavigationDestination(icon: Icon(Icons.note), label: 'Notes'),
NavigationDestination(icon: Icon(Icons.check_box), label: 'Tasks'),
NavigationDestination(
icon: Icon(Icons.chat_bubble), label: 'Chat'),
],
),
);
}
}
class _QuickCaptureBar extends ConsumerStatefulWidget {
const _QuickCaptureBar();
@override
ConsumerState<_QuickCaptureBar> createState() => _QuickCaptureBarState();
}
class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
final _controller = TextEditingController();
bool _busy = false;
@override
void initState() {
super.initState();
// Retry any offline-queued captures from previous sessions.
WidgetsBinding.instance.addPostFrameCallback((_) => _drainQueue());
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _submit() async {
final text = _controller.text.trim();
if (text.isEmpty || _busy) return;
_controller.clear();
setState(() => _busy = true);
// Capture the messenger before the async gap so we can show a snackbar
// even if the user has navigated to a deeper view by the time it resolves.
final messenger = ScaffoldMessenger.of(context);
try {
final result = await ref.read(quickCaptureApiProvider).capture(text);
switch (result.type) {
case 'note':
ref.invalidate(notesProvider);
case 'task':
case 'todo':
ref.invalidate(tasksProvider);
}
final msg = result.message.isNotEmpty
? result.message
: '${_typeLabel(result.type)} created: ${result.title}';
messenger.showSnackBar(
SnackBar(content: Text(msg), behavior: SnackBarBehavior.floating),
);
_drainQueue(); // Silently flush any offline-queued captures.
} on NetworkException {
await ref.read(captureQueueProvider.notifier).enqueue(text);
messenger.showSnackBar(
const SnackBar(
content: Text(
"You're offline — capture saved and will retry automatically."),
behavior: SnackBarBehavior.floating,
),
);
} on AppException catch (e) {
messenger.showSnackBar(
SnackBar(content: Text(e.message), behavior: SnackBarBehavior.floating),
);
} catch (_) {
messenger.showSnackBar(
const SnackBar(
content: Text('Capture failed. Please try again.'),
behavior: SnackBarBehavior.floating,
),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _drainQueue() async {
if (!mounted) return;
final queue = ref.read(captureQueueProvider);
if (queue.isEmpty) return;
final api = ref.read(quickCaptureApiProvider);
for (final text in List<String>.from(queue)) {
if (!mounted) break;
try {
final result = await api.capture(text);
if (!mounted) break;
await ref.read(captureQueueProvider.notifier).dequeue(text);
switch (result.type) {
case 'note':
ref.invalidate(notesProvider);
case 'task':
case 'todo':
ref.invalidate(tasksProvider);
}
} on NetworkException {
break; // Still offline — stop draining.
} catch (_) {
// Server/parse error — remove to avoid infinite retries.
if (mounted) await ref.read(captureQueueProvider.notifier).dequeue(text);
}
}
}
String _typeLabel(String type) => switch (type) {
'note' => 'Note',
'task' => 'Task',
'event' => 'Event',
'todo' => 'To-do',
_ => type,
};
String _hintForLocation(String location) {
if (location.startsWith(Routes.tasks)) return 'Add a task…';
if (location.startsWith(Routes.conversations)) return 'Ask Fabled…';
return 'Capture a note…';
}
@override
Widget build(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
final queueCount = ref.watch(captureQueueProvider).length;
return SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 4, 4),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
enabled: !_busy,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _submit(),
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
hintText: _hintForLocation(location),
isDense: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
prefixIcon: _busy
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: queueCount > 0
? Badge(
label: Text('$queueCount'),
child:
const Icon(Icons.cloud_upload_outlined),
)
: const Icon(Icons.auto_awesome_outlined),
suffixIcon: _controller.text.trim().isNotEmpty && !_busy
? IconButton(
icon: const Icon(Icons.send),
onPressed: _submit,
tooltip: 'Capture',
)
: null,
),
),
),
IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: 'Settings',
onPressed: () => context.push(Routes.settings),
),
],
),
),
);
}
}
class FabledApp extends ConsumerWidget {
const FabledApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
final themeMode = ref.watch(themeModeProvider);
return MaterialApp.router(
title: 'Fabled',
themeMode: themeMode,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.dark,
),
useMaterial3: true,
),
routerConfig: router,
);
}
}