Initial commit: Fabled Android app

Flutter Android client for FabledAssistant with:
- Session-cookie auth via persistent cookie jar (Dio + cookie_jar)
- OAuth/SSO login via in-app WebView (flutter_inappwebview)
- Notes: list, detail (markdown render), create/edit
- Tasks: list with status tabs, create/edit with priority
- Chat: SSE streaming bubbles, conversation management
- Quick Capture FAB for rapid note/task creation
- Settings screen (change server URL, logout)
- Android home screen widget → opens chat
- Riverpod state management, go_router navigation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 21:28:53 -05:00
commit 4da36aa31d
64 changed files with 4053 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../providers/notes_provider.dart';
class NoteDetailScreen extends ConsumerWidget {
final int noteId;
const NoteDetailScreen({super.key, required this.noteId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final noteAsync = ref.watch(noteDetailProvider(noteId));
return Scaffold(
appBar: AppBar(
title: noteAsync.maybeWhen(
data: (n) => Text(n.title),
orElse: () => const Text('Note'),
),
actions: [
noteAsync.maybeWhen(
data: (note) => Row(
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => context.push(
Routes.noteEdit.replaceFirst(':id', '$noteId'),
),
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Delete note?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Delete'),
),
],
),
);
if (confirm == true) {
await ref.read(notesProvider.notifier).delete(noteId);
if (context.mounted) context.pop();
}
},
),
],
),
orElse: () => const SizedBox.shrink(),
),
],
),
body: noteAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (note) => Markdown(
data: note.body,
selectable: true,
),
),
);
}
}
+142
View File
@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/exceptions.dart';
import '../../providers/api_client_provider.dart';
import '../../providers/notes_provider.dart';
class NoteEditScreen extends ConsumerStatefulWidget {
final int? noteId;
const NoteEditScreen({super.key, this.noteId});
@override
ConsumerState<NoteEditScreen> createState() => _NoteEditScreenState();
}
class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
final _titleController = TextEditingController();
final _contentController = TextEditingController();
bool _preview = false;
bool _saving = false;
bool _loaded = false;
@override
void dispose() {
_titleController.dispose();
_contentController.dispose();
super.dispose();
}
Future<void> _loadExisting() async {
if (_loaded || widget.noteId == null) {
_loaded = true;
return;
}
final note =
await ref.read(notesRepositoryProvider).getOne(widget.noteId!);
_titleController.text = note.title;
_contentController.text = note.body;
_loaded = true;
}
Future<void> _save() async {
final title = _titleController.text.trim();
final body = _contentController.text;
if (title.isEmpty) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Title is required.')));
return;
}
setState(() => _saving = true);
try {
if (widget.noteId == null) {
await ref.read(notesProvider.notifier).create(title, body);
if (mounted) context.pop();
} else {
await ref
.read(notesProvider.notifier)
.updateNote(widget.noteId!, title, body);
if (mounted) context.pop();
}
} on AppException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.message)));
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _loadExisting(),
builder: (context, snapshot) {
return Scaffold(
appBar: AppBar(
title: Text(widget.noteId == null ? 'New Note' : 'Edit Note'),
actions: [
IconButton(
icon: Icon(_preview ? Icons.edit : Icons.preview),
tooltip: _preview ? 'Edit' : 'Preview',
onPressed: () => setState(() => _preview = !_preview),
),
IconButton(
icon: _saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
onPressed: _saving ? null : _save,
),
],
),
body: snapshot.connectionState == ConnectionState.waiting
? const Center(child: CircularProgressIndicator())
: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: TextField(
controller: _titleController,
decoration: const InputDecoration(
hintText: 'Title',
border: InputBorder.none,
),
style: Theme.of(context).textTheme.titleLarge,
textInputAction: TextInputAction.next,
),
),
const Divider(),
Expanded(
child: _preview
? Markdown(data: _contentController.text)
: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
controller: _contentController,
decoration: const InputDecoration(
hintText: 'Write in markdown...',
border: InputBorder.none,
),
maxLines: null,
expands: true,
keyboardType: TextInputType.multiline,
textAlignVertical: TextAlignVertical.top,
onChanged: (_) => setState(() {}),
),
),
),
],
),
);
},
);
}
}
+64
View File
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../providers/notes_provider.dart';
class NotesListScreen extends ConsumerWidget {
const NotesListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final notesAsync = ref.watch(notesProvider);
return Scaffold(
appBar: AppBar(title: const Text('Notes')),
body: notesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Error: $e'),
TextButton(
onPressed: () => ref.invalidate(notesProvider),
child: const Text('Retry'),
),
],
),
),
data: (notes) {
if (notes.isEmpty) {
return const Center(child: Text('No notes yet. Tap + to create one.'));
}
return RefreshIndicator(
onRefresh: () => ref.refresh(notesProvider.future),
child: ListView.separated(
itemCount: notes.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) {
final note = notes[i];
return ListTile(
title: Text(note.title),
subtitle: Text(
note.updatedAt.toLocal().toString().substring(0, 16),
style: Theme.of(context).textTheme.bodySmall,
),
onTap: () => context.push(
Routes.noteDetail.replaceFirst(':id', '${note.id}'),
),
);
},
),
);
},
),
floatingActionButton: FloatingActionButton(
heroTag: 'notes_fab',
onPressed: () => context.push(Routes.noteNew),
child: const Icon(Icons.add),
),
);
}
}