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
+174
View File
@@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/exceptions.dart';
import '../../data/models/message.dart';
import '../../providers/chat_provider.dart';
class ChatScreen extends ConsumerStatefulWidget {
final int conversationId;
const ChatScreen({super.key, required this.conversationId});
@override
ConsumerState<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends ConsumerState<ChatScreen> {
final _controller = TextEditingController();
final _scrollController = ScrollController();
@override
void dispose() {
_controller.dispose();
_scrollController.dispose();
super.dispose();
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
Future<void> _send() async {
final text = _controller.text.trim();
if (text.isEmpty) return;
_controller.clear();
try {
await ref
.read(messagesProvider(widget.conversationId).notifier)
.sendMessage(text);
} on AppException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.message)));
}
}
}
@override
Widget build(BuildContext context) {
final messagesAsync = ref.watch(messagesProvider(widget.conversationId));
final isStreaming = ref.watch(isStreamingProvider(widget.conversationId));
// Scroll when messages change
ref.listen(messagesProvider(widget.conversationId), (_, _) {
_scrollToBottom();
});
return Scaffold(
appBar: AppBar(title: const Text('Chat')),
body: Column(
children: [
Expanded(
child: messagesAsync.when(
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (messages) {
if (messages.isEmpty) {
return const Center(
child: Text('Send a message to start.'));
}
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 12),
itemCount: messages.length,
itemBuilder: (context, i) =>
_MessageBubble(message: messages[i]),
);
},
),
),
const Divider(height: 1),
SafeArea(
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'Message...',
border: OutlineInputBorder(),
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
),
maxLines: 4,
minLines: 1,
textInputAction: TextInputAction.newline,
enabled: !isStreaming,
),
),
const SizedBox(width: 8),
IconButton.filled(
onPressed: isStreaming ? null : _send,
icon: isStreaming
? const SizedBox(
width: 20,
height: 20,
child:
CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
),
],
),
),
),
],
),
);
}
}
class _MessageBubble extends StatelessWidget {
final Message message;
const _MessageBubble({required this.message});
@override
Widget build(BuildContext context) {
final isUser = message.role == MessageRole.user;
final scheme = Theme.of(context).colorScheme;
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8,
),
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isUser ? scheme.primary : scheme.surfaceContainerHighest,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(isUser ? 16 : 4),
bottomRight: Radius.circular(isUser ? 4 : 16),
),
),
child: isUser
? Text(
message.content,
style: TextStyle(color: scheme.onPrimary),
)
: MarkdownBody(
data: message.content.isEmpty ? '...' : message.content,
styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)),
),
),
);
}
}
@@ -0,0 +1,122 @@
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/chat_provider.dart';
class ConversationsListScreen extends ConsumerWidget {
const ConversationsListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final convsAsync = ref.watch(conversationsProvider);
return Scaffold(
appBar: AppBar(title: const Text('Chat')),
body: convsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Error: $e'),
TextButton(
onPressed: () => ref.invalidate(conversationsProvider),
child: const Text('Retry'),
),
],
),
),
data: (convs) {
if (convs.isEmpty) {
return const Center(
child: Text('No conversations yet. Tap + to start one.'));
}
return RefreshIndicator(
onRefresh: () => ref.refresh(conversationsProvider.future),
child: ListView.separated(
itemCount: convs.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) {
final conv = convs[i];
return ListTile(
leading: const Icon(Icons.chat_bubble_outline),
title: Text(conv.title),
subtitle: Text(
conv.updatedAt.toLocal().toString().substring(0, 16),
style: Theme.of(context).textTheme.bodySmall,
),
onTap: () => context.push(
Routes.chat.replaceFirst(':id', '${conv.id}'),
),
onLongPress: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Delete conversation?'),
content: Text(conv.title),
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(conversationsProvider.notifier)
.delete(conv.id);
}
},
);
},
),
);
},
),
floatingActionButton: FloatingActionButton(
heroTag: 'chat_fab',
onPressed: () => _newConversation(context, ref),
child: const Icon(Icons.add),
),
);
}
Future<void> _newConversation(BuildContext context, WidgetRef ref) async {
final controller = TextEditingController();
final title = await showDialog<String>(
context: context,
builder: (_) => AlertDialog(
title: const Text('New Conversation'),
content: TextField(
controller: controller,
decoration: const InputDecoration(hintText: 'Title'),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, controller.text.trim()),
child: const Text('Create'),
),
],
),
);
if (title != null && title.isNotEmpty) {
final conv =
await ref.read(conversationsProvider.notifier).create(title);
if (context.mounted) {
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
}
}
}
}