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
+193
View File
@@ -0,0 +1,193 @@
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/auth_provider.dart';
import 'providers/settings_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/quick_capture/quick_capture_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.quickCapture,
builder: (_, _) => const QuickCaptureScreen(),
),
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 ConsumerWidget {
final Widget child;
const _Shell({required this.child});
static const _tabs = [Routes.notes, Routes.tasks, Routes.conversations];
int _tabIndex(String location) {
for (var i = 0; i < _tabs.length; i++) {
if (location.startsWith(_tabs[i])) return i;
}
return 0;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final location = GoRouterState.of(context).matchedLocation;
final index = _tabIndex(location);
return Scaffold(
body: 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'),
],
),
floatingActionButton: FloatingActionButton(
heroTag: 'shell_fab',
onPressed: () => context.push(Routes.quickCapture),
child: const Icon(Icons.add),
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
);
}
}
class FabledApp extends ConsumerWidget {
const FabledApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
return MaterialApp.router(
title: 'Fabled',
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,
);
}
}
+16
View File
@@ -0,0 +1,16 @@
abstract class Routes {
static const splash = '/';
static const setup = '/setup';
static const login = '/login';
static const notes = '/notes';
static const noteDetail = '/notes/:id';
static const noteEdit = '/notes/:id/edit';
static const noteNew = '/notes/new';
static const tasks = '/tasks';
static const taskNew = '/tasks/new';
static const taskEdit = '/tasks/:id/edit';
static const conversations = '/chat';
static const chat = '/chat/:id';
static const quickCapture = '/quick-capture';
static const settings = '/settings';
}
+19
View File
@@ -0,0 +1,19 @@
class AppException implements Exception {
final String message;
const AppException(this.message);
@override
String toString() => message;
}
class NetworkException extends AppException {
const NetworkException(super.message);
}
class AuthException extends AppException {
const AuthException(super.message);
}
class NotFoundException extends AppException {
const NotFoundException(super.message);
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:cookie_jar/cookie_jar.dart';
import 'package:dio/dio.dart';
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
import '../../core/exceptions.dart';
PersistCookieJar buildCookieJar(String cookiesPath) =>
PersistCookieJar(storage: FileStorage('$cookiesPath/.cookies/'));
Dio buildDio(String baseUrl, PersistCookieJar cookieJar) {
final dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {'Content-Type': 'application/json'},
));
dio.interceptors.add(CookieManager(cookieJar));
dio.interceptors.add(_ErrorInterceptor());
return dio;
}
class _ErrorInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if (err.response?.statusCode == 401) {
handler.reject(DioException(
requestOptions: err.requestOptions,
error: const AuthException('Session expired. Please log in again.'),
type: err.type,
response: err.response,
));
return;
}
if (err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.connectionError) {
handler.reject(DioException(
requestOptions: err.requestOptions,
error: const NetworkException('Cannot reach server. Check your connection.'),
type: err.type,
response: err.response,
));
return;
}
handler.next(err);
}
}
AppException dioToApp(DioException e) {
if (e.error is AppException) return e.error as AppException;
final status = e.response?.statusCode;
if (status == 401) return const AuthException('Not authenticated.');
if (status == 404) return const NotFoundException('Resource not found.');
return NetworkException(e.message ?? 'Unknown network error.');
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:dio/dio.dart';
import '../../core/exceptions.dart';
import 'api_client.dart';
class AuthApi {
final Dio _dio;
const AuthApi(this._dio);
Future<void> login(String username, String password) async {
try {
await _dio.post('/api/auth/login', data: {
'username': username,
'password': password,
});
} on DioException catch (e) {
if (e.response?.statusCode == 401) {
throw const AuthException('Invalid username or password.');
}
throw dioToApp(e);
}
}
Future<void> logout() async {
try {
await _dio.post('/api/auth/logout');
} on DioException catch (e) {
throw dioToApp(e);
}
}
Future<bool> verify() async {
try {
await _dio.get('/api/auth/me');
return true;
} on DioException catch (e) {
if (e.response?.statusCode == 401) return false;
throw dioToApp(e);
}
}
Future<Map<String, dynamic>> getStatus() async {
try {
final response = await _dio.get('/api/auth/status');
return response.data as Map<String, dynamic>;
} on DioException catch (e) {
throw dioToApp(e);
}
}
}
+120
View File
@@ -0,0 +1,120 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import '../models/conversation.dart';
import '../models/message.dart';
import 'api_client.dart';
class ChatApi {
final Dio _dio;
const ChatApi(this._dio);
Future<List<Conversation>> getConversations() async {
try {
final response = await _dio.get('/api/chat/conversations');
final data = response.data as Map<String, dynamic>;
final list = data['conversations'] as List<dynamic>;
return list
.map((e) => Conversation.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
Future<Conversation> createConversation(String title) async {
try {
final response = await _dio.post('/api/chat/conversations', data: {
'title': title,
});
return Conversation.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
Future<void> deleteConversation(int id) async {
try {
await _dio.delete('/api/chat/conversations/$id');
} on DioException catch (e) {
throw dioToApp(e);
}
}
// Messages are embedded in the conversation detail response.
Future<List<Message>> getMessages(int conversationId) async {
try {
final response =
await _dio.get('/api/chat/conversations/$conversationId');
final conv = response.data as Map<String, dynamic>;
final list = conv['messages'] as List<dynamic>? ?? [];
return list
.map((e) => Message.fromJson(e as Map<String, dynamic>))
.where((m) => m.status != 'generating') // skip in-flight placeholders
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
// Step 1: POST the user message — server starts background generation.
Future<void> sendMessage(int conversationId, String content) async {
try {
await _dio.post(
'/api/chat/conversations/$conversationId/messages',
data: {'content': content},
);
} on DioException catch (e) {
throw dioToApp(e);
}
}
// Step 2: GET the SSE stream and yield text chunks.
Stream<String> streamGeneration(int conversationId) async* {
try {
final response = await _dio.get(
'/api/chat/conversations/$conversationId/generation/stream',
options: Options(
responseType: ResponseType.stream,
headers: {'Accept': 'text/event-stream'},
),
);
final stream = (response.data as ResponseBody).stream;
final buf = StringBuffer();
String currentEvent = '';
await for (final chunk in stream) {
buf.write(utf8.decode(chunk));
final raw = buf.toString();
final lines = raw.split('\n');
// Keep the last (potentially incomplete) line in the buffer.
buf.clear();
buf.write(lines.last);
for (final line in lines.sublist(0, lines.length - 1)) {
if (line.startsWith('event: ')) {
currentEvent = line.substring(7).trim();
} else if (line.startsWith('data: ')) {
final jsonStr = line.substring(6).trim();
if (currentEvent == 'chunk') {
try {
final data = json.decode(jsonStr) as Map<String, dynamic>;
final text = data['text'] as String? ?? '';
if (text.isNotEmpty) yield text;
} catch (_) {}
} else if (currentEvent == 'done' || currentEvent == 'error') {
return;
}
} else if (line.isEmpty) {
currentEvent = ''; // blank line = SSE event separator
}
}
}
} on DioException catch (e) {
throw dioToApp(e);
}
}
}
+61
View File
@@ -0,0 +1,61 @@
import 'package:dio/dio.dart';
import '../models/note.dart';
import 'api_client.dart';
class NotesApi {
final Dio _dio;
const NotesApi(this._dio);
Future<List<Note>> getAll() async {
try {
final response = await _dio.get('/api/notes');
final data = response.data as Map<String, dynamic>;
final list = data['notes'] as List<dynamic>;
return list.map((e) => Note.fromJson(e as Map<String, dynamic>)).toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
Future<Note> getOne(int id) async {
try {
final response = await _dio.get('/api/notes/$id');
return Note.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
Future<Note> create(String title, String body) async {
try {
final response = await _dio.post('/api/notes', data: {
'title': title,
'body': body,
});
return Note.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
Future<Note> update(int id, String title, String body) async {
try {
final response = await _dio.put('/api/notes/$id', data: {
'title': title,
'body': body,
});
return Note.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
Future<void> delete(int id) async {
try {
await _dio.delete('/api/notes/$id');
} on DioException catch (e) {
throw dioToApp(e);
}
}
}
+67
View File
@@ -0,0 +1,67 @@
import 'package:dio/dio.dart';
import '../models/task.dart';
import 'api_client.dart';
class TasksApi {
final Dio _dio;
const TasksApi(this._dio);
Future<List<Task>> getAll() async {
try {
final response = await _dio.get('/api/tasks');
final data = response.data as Map<String, dynamic>;
final list = data['tasks'] as List<dynamic>;
return list.map((e) => Task.fromJson(e as Map<String, dynamic>)).toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
Future<Task> getOne(int id) async {
try {
final response = await _dio.get('/api/tasks/$id');
return Task.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
Future<Task> create({
required String title,
String? description,
required TaskStatus status,
required TaskPriority priority,
DateTime? dueDate,
}) async {
try {
final response = await _dio.post('/api/tasks', data: {
'title': title,
'body': description,
'status': status.value,
'priority': priority.value,
'due_date': dueDate?.toIso8601String(),
});
return Task.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
Future<Task> update(int id, Map<String, dynamic> fields) async {
try {
final response = await _dio.put('/api/tasks/$id', data: fields);
return Task.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
Future<void> delete(int id) async {
try {
await _dio.delete('/api/tasks/$id');
} on DioException catch (e) {
throw dioToApp(e);
}
}
}
+20
View File
@@ -0,0 +1,20 @@
class Conversation {
final int id;
final String title;
final DateTime createdAt;
final DateTime updatedAt;
const Conversation({
required this.id,
required this.title,
required this.createdAt,
required this.updatedAt,
});
factory Conversation.fromJson(Map<String, dynamic> json) => Conversation(
id: json['id'] as int,
title: json['title'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
}
+39
View File
@@ -0,0 +1,39 @@
enum MessageRole { user, assistant }
class Message {
final int? id;
final int conversationId;
final MessageRole role;
final String content;
final String status; // "complete" | "generating"
final DateTime? createdAt;
const Message({
this.id,
required this.conversationId,
required this.role,
required this.content,
this.status = 'complete',
this.createdAt,
});
factory Message.fromJson(Map<String, dynamic> json) => Message(
id: json['id'] as int?,
conversationId: json['conversation_id'] as int,
role: json['role'] == 'user' ? MessageRole.user : MessageRole.assistant,
content: json['content'] as String,
status: json['status'] as String? ?? 'complete',
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'] as String)
: null,
);
Message copyWith({String? content, String? status}) => Message(
id: id,
conversationId: conversationId,
role: role,
content: content ?? this.content,
status: status ?? this.status,
createdAt: createdAt,
);
}
+36
View File
@@ -0,0 +1,36 @@
class Note {
final int id;
final String title;
final String body;
final DateTime createdAt;
final DateTime updatedAt;
const Note({
required this.id,
required this.title,
required this.body,
required this.createdAt,
required this.updatedAt,
});
factory Note.fromJson(Map<String, dynamic> json) => Note(
id: json['id'] as int,
title: json['title'] as String? ?? '',
body: json['body'] as String? ?? '',
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
Map<String, dynamic> toJson() => {
'title': title,
'body': body,
};
Note copyWith({String? title, String? body}) => Note(
id: id,
title: title ?? this.title,
body: body ?? this.body,
createdAt: createdAt,
updatedAt: updatedAt,
);
}
+107
View File
@@ -0,0 +1,107 @@
enum TaskStatus { todo, inProgress, done }
enum TaskPriority { none, low, medium, high }
extension TaskStatusExtension on TaskStatus {
String get value => switch (this) {
TaskStatus.todo => 'todo',
TaskStatus.inProgress => 'in_progress',
TaskStatus.done => 'done',
};
String get label => switch (this) {
TaskStatus.todo => 'To Do',
TaskStatus.inProgress => 'In Progress',
TaskStatus.done => 'Done',
};
static TaskStatus fromString(String? s) => switch (s) {
'in_progress' => TaskStatus.inProgress,
'done' => TaskStatus.done,
_ => TaskStatus.todo,
};
}
extension TaskPriorityExtension on TaskPriority {
String get value => switch (this) {
TaskPriority.none => 'none',
TaskPriority.low => 'low',
TaskPriority.medium => 'medium',
TaskPriority.high => 'high',
};
String get label => switch (this) {
TaskPriority.none => 'None',
TaskPriority.low => 'Low',
TaskPriority.medium => 'Medium',
TaskPriority.high => 'High',
};
static TaskPriority fromString(String? s) => switch (s) {
'high' => TaskPriority.high,
'medium' => TaskPriority.medium,
'low' => TaskPriority.low,
_ => TaskPriority.none,
};
}
class Task {
final int id;
final String title;
final String? description;
final TaskStatus status;
final TaskPriority priority;
final DateTime? dueDate;
final DateTime createdAt;
final DateTime updatedAt;
const Task({
required this.id,
required this.title,
this.description,
required this.status,
required this.priority,
this.dueDate,
required this.createdAt,
required this.updatedAt,
});
factory Task.fromJson(Map<String, dynamic> json) => Task(
id: json['id'] as int,
title: json['title'] as String? ?? '',
description: json['body'] as String?,
status: TaskStatusExtension.fromString(json['status'] as String?),
priority: TaskPriorityExtension.fromString(json['priority'] as String?),
dueDate: json['due_date'] != null
? DateTime.parse(json['due_date'] as String)
: null,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
Map<String, dynamic> toJson() => {
'title': title,
'body': description,
'status': status.value,
'priority': priority.value,
'due_date': dueDate?.toIso8601String(),
};
Task copyWith({
String? title,
String? description,
TaskStatus? status,
TaskPriority? priority,
DateTime? dueDate,
}) =>
Task(
id: id,
title: title ?? this.title,
description: description ?? this.description,
status: status ?? this.status,
priority: priority ?? this.priority,
dueDate: dueDate ?? this.dueDate,
createdAt: createdAt,
updatedAt: updatedAt,
);
}
@@ -0,0 +1,15 @@
import '../api/auth_api.dart';
class AuthRepository {
final AuthApi _api;
const AuthRepository(this._api);
Future<void> login(String username, String password) =>
_api.login(username, password);
Future<void> logout() => _api.logout();
Future<bool> verify() => _api.verify();
Future<Map<String, dynamic>> getStatus() => _api.getStatus();
}
@@ -0,0 +1,19 @@
import '../api/chat_api.dart';
import '../models/conversation.dart';
import '../models/message.dart';
class ChatRepository {
final ChatApi _api;
const ChatRepository(this._api);
Future<List<Conversation>> getConversations() => _api.getConversations();
Future<Conversation> createConversation(String title) =>
_api.createConversation(title);
Future<void> deleteConversation(int id) => _api.deleteConversation(id);
Future<List<Message>> getMessages(int conversationId) =>
_api.getMessages(conversationId);
Future<void> sendMessage(int conversationId, String content) =>
_api.sendMessage(conversationId, content);
Stream<String> streamGeneration(int conversationId) =>
_api.streamGeneration(conversationId);
}
@@ -0,0 +1,15 @@
import '../api/notes_api.dart';
import '../models/note.dart';
class NotesRepository {
final NotesApi _api;
const NotesRepository(this._api);
Future<List<Note>> getAll() => _api.getAll();
Future<Note> getOne(int id) => _api.getOne(id);
Future<Note> create(String title, String body) =>
_api.create(title, body);
Future<Note> update(int id, String title, String body) =>
_api.update(id, title, body);
Future<void> delete(int id) => _api.delete(id);
}
@@ -0,0 +1,30 @@
import '../api/tasks_api.dart';
import '../models/task.dart';
class TasksRepository {
final TasksApi _api;
const TasksRepository(this._api);
Future<List<Task>> getAll() => _api.getAll();
Future<Task> getOne(int id) => _api.getOne(id);
Future<Task> create({
required String title,
String? description,
required TaskStatus status,
required TaskPriority priority,
DateTime? dueDate,
}) =>
_api.create(
title: title,
description: description,
status: status,
priority: priority,
dueDate: dueDate,
);
Future<Task> update(int id, Map<String, dynamic> fields) =>
_api.update(id, fields);
Future<void> delete(int id) => _api.delete(id);
}
+55
View File
@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app.dart';
import 'providers/settings_provider.dart';
const _widgetChannel = MethodChannel('com.fabledapp.fabled_app/widget');
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final prefs = await SharedPreferences.getInstance();
final appDir = await getApplicationDocumentsDirectory();
runApp(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
cookiesPathProvider.overrideWithValue(appDir.path),
],
child: const _WidgetIntentListener(child: FabledApp()),
),
);
}
// Listens for MethodChannel calls from the native Android widget.
class _WidgetIntentListener extends StatefulWidget {
final Widget child;
const _WidgetIntentListener({required this.child});
@override
State<_WidgetIntentListener> createState() => _WidgetIntentListenerState();
}
class _WidgetIntentListenerState extends State<_WidgetIntentListener> {
@override
void initState() {
super.initState();
_widgetChannel.setMethodCallHandler((call) async {
if (call.method == 'openChat') {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
Navigator.of(context, rootNavigator: true)
.pushNamedAndRemoveUntil('/chat', (_) => false);
}
});
}
});
}
@override
Widget build(BuildContext context) => widget.child;
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:cookie_jar/cookie_jar.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/api/api_client.dart';
import '../data/api/auth_api.dart';
import '../data/api/chat_api.dart';
import '../data/api/notes_api.dart';
import '../data/api/tasks_api.dart';
import '../data/repositories/auth_repository.dart';
import '../data/repositories/chat_repository.dart';
import '../data/repositories/notes_repository.dart';
import '../data/repositories/tasks_repository.dart';
import 'settings_provider.dart';
final cookieJarProvider = Provider<PersistCookieJar>((ref) {
final cookiesPath = ref.watch(cookiesPathProvider);
return buildCookieJar(cookiesPath);
});
final dioProvider = Provider<Dio>((ref) {
final url = ref.watch(serverUrlProvider) ?? '';
final jar = ref.watch(cookieJarProvider);
return buildDio(url, jar);
});
final authApiProvider = Provider<AuthApi>((ref) {
return AuthApi(ref.watch(dioProvider));
});
final notesApiProvider = Provider<NotesApi>((ref) {
return NotesApi(ref.watch(dioProvider));
});
final tasksApiProvider = Provider<TasksApi>((ref) {
return TasksApi(ref.watch(dioProvider));
});
final chatApiProvider = Provider<ChatApi>((ref) {
return ChatApi(ref.watch(dioProvider));
});
final authRepositoryProvider = Provider<AuthRepository>((ref) {
return AuthRepository(ref.watch(authApiProvider));
});
final notesRepositoryProvider = Provider<NotesRepository>((ref) {
return NotesRepository(ref.watch(notesApiProvider));
});
final tasksRepositoryProvider = Provider<TasksRepository>((ref) {
return TasksRepository(ref.watch(tasksApiProvider));
});
final chatRepositoryProvider = Provider<ChatRepository>((ref) {
return ChatRepository(ref.watch(chatApiProvider));
});
+40
View File
@@ -0,0 +1,40 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'api_client_provider.dart';
enum AuthStatus { unknown, authenticated, unauthenticated }
final authProvider = StateNotifierProvider<AuthNotifier, AuthStatus>((ref) {
return AuthNotifier(ref);
});
class AuthNotifier extends StateNotifier<AuthStatus> {
final Ref _ref;
AuthNotifier(this._ref) : super(AuthStatus.unknown);
Future<void> verify() async {
try {
final repo = _ref.read(authRepositoryProvider);
final ok = await repo.verify();
state = ok ? AuthStatus.authenticated : AuthStatus.unauthenticated;
} catch (_) {
state = AuthStatus.unauthenticated;
}
}
Future<void> login(String username, String password) async {
final repo = _ref.read(authRepositoryProvider);
await repo.login(username, password);
state = AuthStatus.authenticated;
}
Future<void> logout() async {
try {
final repo = _ref.read(authRepositoryProvider);
await repo.logout();
} finally {
state = AuthStatus.unauthenticated;
}
}
}
+98
View File
@@ -0,0 +1,98 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/models/conversation.dart';
import '../data/models/message.dart';
import 'api_client_provider.dart';
final conversationsProvider =
AsyncNotifierProvider<ConversationsNotifier, List<Conversation>>(
ConversationsNotifier.new);
class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
@override
Future<List<Conversation>> build() async {
return ref.watch(chatRepositoryProvider).getConversations();
}
Future<Conversation> create(String title) async {
final conv =
await ref.read(chatRepositoryProvider).createConversation(title);
state = AsyncData([conv, ...state.valueOrNull ?? []]);
return conv;
}
Future<void> delete(int id) async {
await ref.read(chatRepositoryProvider).deleteConversation(id);
state = AsyncData([
for (final c in state.valueOrNull ?? [])
if (c.id != id) c,
]);
}
}
final messagesProvider = AsyncNotifierProvider.family<MessagesNotifier,
List<Message>, int>(MessagesNotifier.new);
class MessagesNotifier extends FamilyAsyncNotifier<List<Message>, int> {
bool _isStreaming = false;
bool get isStreaming => _isStreaming;
@override
Future<List<Message>> build(int arg) async {
return ref.watch(chatRepositoryProvider).getMessages(arg);
}
Future<void> sendMessage(String content) async {
final conversationId = arg;
final repo = ref.read(chatRepositoryProvider);
// Optimistically add the user message.
final userMsg = Message(
conversationId: conversationId,
role: MessageRole.user,
content: content,
);
state = AsyncData([...state.valueOrNull ?? [], userMsg]);
// Add an empty assistant placeholder.
final placeholder = Message(
conversationId: conversationId,
role: MessageRole.assistant,
content: '',
status: 'generating',
);
state = AsyncData([...state.requireValue, placeholder]);
_isStreaming = true;
try {
// Step 1: POST the message (fires background generation on server).
await repo.sendMessage(conversationId, content);
// Step 2: Stream chunks and update the placeholder in place.
await for (final chunk in repo.streamGeneration(conversationId)) {
final msgs = state.requireValue;
final last = msgs.last.copyWith(content: msgs.last.content + chunk);
state = AsyncData([...msgs.sublist(0, msgs.length - 1), last]);
}
// Mark the assistant message as complete.
final msgs = state.requireValue;
final last = msgs.last.copyWith(status: 'complete');
state = AsyncData([...msgs.sublist(0, msgs.length - 1), last]);
} catch (e) {
// Remove the placeholder on error so the user can retry.
state = AsyncData([
for (final m in state.valueOrNull ?? [])
if (m.status != 'generating') m,
]);
rethrow;
} finally {
_isStreaming = false;
}
}
}
final isStreamingProvider = Provider.family<bool, int>((ref, convId) {
final notifier = ref.watch(messagesProvider(convId).notifier);
return notifier.isStreaming;
});
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/models/note.dart';
import 'api_client_provider.dart';
final notesProvider =
AsyncNotifierProvider<NotesNotifier, List<Note>>(NotesNotifier.new);
class NotesNotifier extends AsyncNotifier<List<Note>> {
@override
Future<List<Note>> build() async {
return ref.watch(notesRepositoryProvider).getAll();
}
Future<Note> create(String title, String body) async {
final note =
await ref.read(notesRepositoryProvider).create(title, body);
state = AsyncData([...state.valueOrNull ?? [], note]);
return note;
}
Future<Note> updateNote(int id, String title, String body) async {
final updated =
await ref.read(notesRepositoryProvider).update(id, title, body);
state = AsyncData([
for (final n in state.valueOrNull ?? [])
if (n.id == id) updated else n,
]);
return updated;
}
Future<void> delete(int id) async {
await ref.read(notesRepositoryProvider).delete(id);
state = AsyncData([
for (final n in state.valueOrNull ?? [])
if (n.id != id) n,
]);
}
}
final noteDetailProvider =
FutureProvider.family<Note, int>((ref, id) async {
return ref.watch(notesRepositoryProvider).getOne(id);
});
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _kServerUrl = 'server_url';
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
throw UnimplementedError('Override in ProviderScope');
});
final cookiesPathProvider = Provider<String>((ref) {
throw UnimplementedError('Override in ProviderScope');
});
final serverUrlProvider = StateNotifierProvider<ServerUrlNotifier, String?>((ref) {
final prefs = ref.watch(sharedPreferencesProvider);
return ServerUrlNotifier(prefs);
});
class ServerUrlNotifier extends StateNotifier<String?> {
final SharedPreferences _prefs;
ServerUrlNotifier(this._prefs) : super(_prefs.getString(_kServerUrl));
Future<void> setUrl(String url) async {
// Strip trailing slash
final clean = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
await _prefs.setString(_kServerUrl, clean);
state = clean;
}
Future<void> clear() async {
await _prefs.remove(_kServerUrl);
state = null;
}
}
+49
View File
@@ -0,0 +1,49 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/models/task.dart';
import 'api_client_provider.dart';
final tasksProvider =
AsyncNotifierProvider<TasksNotifier, List<Task>>(TasksNotifier.new);
class TasksNotifier extends AsyncNotifier<List<Task>> {
@override
Future<List<Task>> build() async {
return ref.watch(tasksRepositoryProvider).getAll();
}
Future<Task> create({
required String title,
String? description,
required TaskStatus status,
required TaskPriority priority,
DateTime? dueDate,
}) async {
final task = await ref.read(tasksRepositoryProvider).create(
title: title,
description: description,
status: status,
priority: priority,
dueDate: dueDate,
);
state = AsyncData([...state.valueOrNull ?? [], task]);
return task;
}
Future<Task> updateTask(int id, Map<String, dynamic> fields) async {
final updated = await ref.read(tasksRepositoryProvider).update(id, fields);
state = AsyncData([
for (final t in state.valueOrNull ?? [])
if (t.id == id) updated else t,
]);
return updated;
}
Future<void> delete(int id) async {
await ref.read(tasksRepositoryProvider).delete(id);
state = AsyncData([
for (final t in state.valueOrNull ?? [])
if (t.id != id) t,
]);
}
}
+280
View File
@@ -0,0 +1,280 @@
import 'dart:io' as io;
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.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/settings_provider.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
bool _loadingStatus = true;
bool _oauthEnabled = false;
bool _localAuthEnabled = false;
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _loading = false;
bool _obscure = true;
String? _error;
@override
void initState() {
super.initState();
_loadStatus();
}
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _loadStatus() async {
try {
final status = await ref.read(authRepositoryProvider).getStatus();
if (!mounted) return;
setState(() {
_oauthEnabled = status['oauth_enabled'] as bool? ?? false;
_localAuthEnabled = status['local_auth_enabled'] as bool? ?? true;
_loadingStatus = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_localAuthEnabled = true;
_loadingStatus = false;
});
}
}
Future<void> _localLogin() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_loading = true;
_error = null;
});
try {
await ref.read(authProvider.notifier).login(
_usernameController.text.trim(),
_passwordController.text,
);
if (mounted) context.go(Routes.notes);
} on AuthException catch (e) {
setState(() => _error = e.message);
} on AppException catch (e) {
setState(() => _error = e.message);
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _openOAuth() {
final serverUrl = ref.read(serverUrlProvider) ?? '';
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => _OAuthWebView(
serverUrl: serverUrl,
cookieJar: ref.read(cookieJarProvider),
onSuccess: () async {
await ref.read(authProvider.notifier).verify();
if (mounted) context.go(Routes.notes);
},
),
));
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: _loadingStatus
? const Center(child: CircularProgressIndicator())
: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Sign In',
style: TextStyle(
fontSize: 28, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
if (_oauthEnabled) ...[
FilledButton.icon(
icon: const Icon(Icons.login),
label: const Text('Sign in with SSO'),
onPressed: _openOAuth,
),
],
if (_oauthEnabled && _localAuthEnabled) ...[
const SizedBox(height: 16),
const Row(children: [
Expanded(child: Divider()),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('or'),
),
Expanded(child: Divider()),
]),
const SizedBox(height: 16),
],
if (_localAuthEnabled) ...[
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Username',
border: OutlineInputBorder()),
textInputAction: TextInputAction.next,
autocorrect: false,
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_obscure
? Icons.visibility
: Icons.visibility_off),
onPressed: () =>
setState(() => _obscure = !_obscure),
),
),
obscureText: _obscure,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _localLogin(),
validator: (v) =>
(v == null || v.isEmpty) ? 'Required' : null,
),
if (_error != null) ...[
const SizedBox(height: 8),
Text(_error!,
style: TextStyle(
color:
Theme.of(context).colorScheme.error)),
],
const SizedBox(height: 24),
FilledButton(
onPressed: _loading ? null : _localLogin,
child: _loading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2),
)
: const Text('Sign In'),
),
],
if (!_oauthEnabled && !_localAuthEnabled)
Text(
'No sign-in method is available. Check server configuration.',
style: TextStyle(
color: Theme.of(context).colorScheme.error),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
TextButton(
onPressed: () => context.go(Routes.setup),
child: const Text('Change server'),
),
],
),
),
),
),
);
}
}
// ---------------------------------------------------------------------------
// In-app WebView that handles the server-side OAuth redirect flow.
// ---------------------------------------------------------------------------
class _OAuthWebView extends StatefulWidget {
final String serverUrl;
final dynamic cookieJar; // PersistCookieJar — avoid importing cookie_jar here
final Future<void> Function() onSuccess;
const _OAuthWebView({
required this.serverUrl,
required this.cookieJar,
required this.onSuccess,
});
@override
State<_OAuthWebView> createState() => _OAuthWebViewState();
}
class _OAuthWebViewState extends State<_OAuthWebView> {
bool _completing = false;
Future<void> _handleUrl(String url) async {
if (_completing) return;
// The server redirects to "/" on successful OAuth completion.
final root = widget.serverUrl.endsWith('/')
? widget.serverUrl
: '${widget.serverUrl}/';
if (url == widget.serverUrl || url == root) {
_completing = true;
// Copy cookies from Android WebView store → Dio PersistCookieJar.
final cookieManager = CookieManager.instance();
final wvCookies =
await cookieManager.getCookies(url: WebUri(widget.serverUrl));
final serverUri = Uri.parse(widget.serverUrl);
final ioCookies = wvCookies.map((c) {
final dc = io.Cookie(c.name, c.value.toString());
var domain = c.domain ?? serverUri.host;
if (domain.startsWith('.')) domain = domain.substring(1);
dc.domain = domain;
dc.path = c.path ?? '/';
return dc;
}).toList();
await widget.cookieJar.saveFromResponse(serverUri, ioCookies);
if (mounted) {
Navigator.of(context).pop();
await widget.onSuccess();
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Sign in'),
leading: const CloseButton(),
),
body: InAppWebView(
initialUrlRequest: URLRequest(
url: WebUri('${widget.serverUrl}/api/auth/oauth/login'),
),
onLoadStop: (controller, url) async {
if (url != null) await _handleUrl(url.toString());
},
),
);
}
}
+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}'));
}
}
}
}
+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),
),
);
}
}
@@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/exceptions.dart';
import '../../data/models/task.dart';
import '../../providers/notes_provider.dart';
import '../../providers/tasks_provider.dart';
class QuickCaptureScreen extends ConsumerStatefulWidget {
const QuickCaptureScreen({super.key});
@override
ConsumerState<QuickCaptureScreen> createState() =>
_QuickCaptureScreenState();
}
class _QuickCaptureScreenState extends ConsumerState<QuickCaptureScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabs;
final _noteTitleController = TextEditingController();
final _noteContentController = TextEditingController();
final _taskTitleController = TextEditingController();
TaskPriority _taskPriority = TaskPriority.medium;
bool _saving = false;
@override
void initState() {
super.initState();
_tabs = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabs.dispose();
_noteTitleController.dispose();
_noteContentController.dispose();
_taskTitleController.dispose();
super.dispose();
}
Future<void> _saveNote() async {
final title = _noteTitleController.text.trim();
if (title.isEmpty) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Title is required.')));
return;
}
setState(() => _saving = true);
try {
await ref
.read(notesProvider.notifier)
.create(title, _noteContentController.text);
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);
}
}
Future<void> _saveTask() async {
final title = _taskTitleController.text.trim();
if (title.isEmpty) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Title is required.')));
return;
}
setState(() => _saving = true);
try {
await ref.read(tasksProvider.notifier).create(
title: title,
status: TaskStatus.todo,
priority: _taskPriority,
);
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 Scaffold(
appBar: AppBar(
title: const Text('Quick Capture'),
bottom: TabBar(
controller: _tabs,
tabs: const [Tab(text: 'Note'), Tab(text: 'Task')],
),
),
body: TabBarView(
controller: _tabs,
children: [
// Note tab
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: _noteTitleController,
decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
autofocus: true,
),
const SizedBox(height: 12),
Expanded(
child: TextField(
controller: _noteContentController,
decoration: const InputDecoration(
hintText: 'Content (optional, supports markdown)',
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
keyboardType: TextInputType.multiline,
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _saving ? null : _saveNote,
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Save Note'),
),
),
],
),
),
// Task tab
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: _taskTitleController,
decoration: const InputDecoration(
labelText: 'Task title',
border: OutlineInputBorder(),
),
autofocus: true,
),
const SizedBox(height: 12),
DropdownButtonFormField<TaskPriority>(
initialValue: _taskPriority,
decoration: const InputDecoration(
labelText: 'Priority',
border: OutlineInputBorder(),
),
items: TaskPriority.values
.map((p) =>
DropdownMenuItem(value: p, child: Text(p.label)))
.toList(),
onChanged: (v) => setState(() => _taskPriority = v!),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _saving ? null : _saveTask,
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Save Task'),
),
),
],
),
),
],
),
);
}
}
+39
View File
@@ -0,0 +1,39 @@
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/auth_provider.dart';
import '../../providers/settings_provider.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final serverUrl = ref.watch(serverUrlProvider);
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
children: [
ListTile(
title: const Text('Server URL'),
subtitle: Text(serverUrl ?? 'Not configured'),
leading: const Icon(Icons.dns),
onTap: () => context.go(Routes.setup),
),
const Divider(),
ListTile(
title: const Text('Sign Out'),
leading: const Icon(Icons.logout),
onTap: () async {
await ref.read(authProvider.notifier).logout();
if (context.mounted) context.go(Routes.login);
},
),
],
),
);
}
}
+123
View File
@@ -0,0 +1,123 @@
import 'package:dio/dio.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 '../../providers/settings_provider.dart';
class SetupScreen extends ConsumerStatefulWidget {
const SetupScreen({super.key});
@override
ConsumerState<SetupScreen> createState() => _SetupScreenState();
}
class _SetupScreenState extends ConsumerState<SetupScreen> {
final _formKey = GlobalKey<FormState>();
final _urlController = TextEditingController();
bool _testing = false;
String? _error;
@override
void dispose() {
_urlController.dispose();
super.dispose();
}
Future<void> _testAndSave() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_testing = true;
_error = null;
});
var url = _urlController.text.trim();
if (url.endsWith('/')) url = url.substring(0, url.length - 1);
try {
final dio = Dio(BaseOptions(connectTimeout: const Duration(seconds: 5)));
await dio.get('$url/api/auth/status');
// 401 is fine — server is reachable
} on DioException catch (_) {
setState(() {
_testing = false;
_error = 'Could not reach server. Check the URL and try again.';
});
return;
} catch (e) {
setState(() {
_testing = false;
_error = 'Could not reach server. Check the URL and try again.';
});
return;
}
await ref.read(serverUrlProvider.notifier).setUrl(url);
if (mounted) context.go(Routes.login);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Welcome to Fabled',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const Text(
'Enter your FabledAssistant server URL to get started.',
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextFormField(
controller: _urlController,
decoration: const InputDecoration(
labelText: 'Server URL',
hintText: 'https://fabled.example.com',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.url,
autocorrect: false,
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Required';
final uri = Uri.tryParse(v.trim());
if (uri == null || !uri.hasScheme) {
return 'Enter a valid URL (include http:// or https://)';
}
return null;
},
),
if (_error != null) ...[
const SizedBox(height: 8),
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
const SizedBox(height: 24),
FilledButton(
onPressed: _testing ? null : _testAndSave,
child: _testing
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Connect'),
),
],
),
),
),
),
);
}
}
+54
View File
@@ -0,0 +1,54 @@
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/auth_provider.dart';
import '../../providers/settings_provider.dart';
class SplashScreen extends ConsumerStatefulWidget {
const SplashScreen({super.key});
@override
ConsumerState<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends ConsumerState<SplashScreen> {
@override
void initState() {
super.initState();
_check();
}
Future<void> _check() async {
final serverUrl = ref.read(serverUrlProvider);
if (serverUrl == null || serverUrl.isEmpty) {
if (mounted) context.go(Routes.setup);
return;
}
await ref.read(authProvider.notifier).verify();
if (!mounted) return;
final status = ref.read(authProvider);
if (status == AuthStatus.authenticated) {
context.go(Routes.notes);
} else {
context.go(Routes.login);
}
}
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Fabled', style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold)),
SizedBox(height: 24),
CircularProgressIndicator(),
],
),
),
);
}
}
+216
View File
@@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/exceptions.dart';
import '../../data/models/task.dart';
import '../../providers/api_client_provider.dart';
import '../../providers/tasks_provider.dart';
class TaskEditScreen extends ConsumerStatefulWidget {
final int? taskId;
const TaskEditScreen({super.key, this.taskId});
@override
ConsumerState<TaskEditScreen> createState() => _TaskEditScreenState();
}
class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descController = TextEditingController();
TaskStatus _status = TaskStatus.todo;
TaskPriority _priority = TaskPriority.medium;
DateTime? _dueDate;
bool _saving = false;
bool _loaded = false;
@override
void dispose() {
_titleController.dispose();
_descController.dispose();
super.dispose();
}
Future<void> _loadExisting() async {
if (_loaded || widget.taskId == null) {
_loaded = true;
return;
}
final task = await ref.read(tasksRepositoryProvider).getOne(widget.taskId!);
_titleController.text = task.title;
_descController.text = task.description ?? '';
_status = task.status;
_priority = task.priority;
_dueDate = task.dueDate;
_loaded = true;
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
try {
if (widget.taskId == null) {
await ref.read(tasksProvider.notifier).create(
title: _titleController.text.trim(),
description: _descController.text.trim().isEmpty
? null
: _descController.text.trim(),
status: _status,
priority: _priority,
dueDate: _dueDate,
);
} else {
await ref.read(tasksProvider.notifier).updateTask(widget.taskId!, {
'title': _titleController.text.trim(),
'description': _descController.text.trim().isEmpty
? null
: _descController.text.trim(),
'status': _status.value,
'priority': _priority.value,
'due_date': _dueDate?.toIso8601String(),
});
}
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);
}
}
Future<void> _delete() async {
final confirm = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Delete task?'),
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(tasksProvider.notifier).delete(widget.taskId!);
if (mounted) context.pop();
}
}
Future<void> _pickDate() async {
final date = await showDatePicker(
context: context,
initialDate: _dueDate ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2100),
);
if (date != null) setState(() => _dueDate = date);
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _loadExisting(),
builder: (context, snapshot) {
return Scaffold(
appBar: AppBar(
title: Text(widget.taskId == null ? 'New Task' : 'Edit Task'),
actions: [
if (widget.taskId != null)
IconButton(
icon: const Icon(Icons.delete),
onPressed: _delete,
),
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())
: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _descController,
decoration: const InputDecoration(
labelText: 'Description (optional)',
border: OutlineInputBorder(),
),
maxLines: 3,
),
const SizedBox(height: 16),
DropdownButtonFormField<TaskStatus>(
initialValue: _status,
decoration: const InputDecoration(
labelText: 'Status',
border: OutlineInputBorder(),
),
items: TaskStatus.values
.map((s) => DropdownMenuItem(
value: s, child: Text(s.label)))
.toList(),
onChanged: (v) => setState(() => _status = v!),
),
const SizedBox(height: 16),
DropdownButtonFormField<TaskPriority>(
initialValue: _priority,
decoration: const InputDecoration(
labelText: 'Priority',
border: OutlineInputBorder(),
),
items: TaskPriority.values
.map((p) => DropdownMenuItem(
value: p, child: Text(p.label)))
.toList(),
onChanged: (v) => setState(() => _priority = v!),
),
const SizedBox(height: 16),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(_dueDate == null
? 'No due date'
: 'Due: ${_dueDate!.toLocal().toString().substring(0, 10)}'),
leading: const Icon(Icons.calendar_today),
trailing: _dueDate != null
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () =>
setState(() => _dueDate = null),
)
: null,
onTap: _pickDate,
),
],
),
),
);
},
);
}
}
+129
View File
@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../data/models/task.dart';
import '../../providers/tasks_provider.dart';
class TasksListScreen extends ConsumerStatefulWidget {
const TasksListScreen({super.key});
@override
ConsumerState<TasksListScreen> createState() => _TasksListScreenState();
}
class _TasksListScreenState extends ConsumerState<TasksListScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabs;
@override
void initState() {
super.initState();
_tabs = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabs.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final tasksAsync = ref.watch(tasksProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Tasks'),
bottom: TabBar(
controller: _tabs,
tabs: const [
Tab(text: 'To Do'),
Tab(text: 'In Progress'),
Tab(text: 'Done'),
],
),
),
body: tasksAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Error: $e'),
TextButton(
onPressed: () => ref.invalidate(tasksProvider),
child: const Text('Retry'),
),
],
),
),
data: (tasks) {
final todo =
tasks.where((t) => t.status == TaskStatus.todo).toList();
final inProgress =
tasks.where((t) => t.status == TaskStatus.inProgress).toList();
final done =
tasks.where((t) => t.status == TaskStatus.done).toList();
return RefreshIndicator(
onRefresh: () => ref.refresh(tasksProvider.future),
child: TabBarView(
controller: _tabs,
children: [
_TaskList(tasks: todo),
_TaskList(tasks: inProgress),
_TaskList(tasks: done),
],
),
);
},
),
floatingActionButton: FloatingActionButton(
heroTag: 'tasks_fab',
onPressed: () => context.push(Routes.taskNew),
child: const Icon(Icons.add),
),
);
}
}
class _TaskList extends ConsumerWidget {
final List<Task> tasks;
const _TaskList({required this.tasks});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (tasks.isEmpty) {
return const Center(child: Text('No tasks here.'));
}
return ListView.separated(
itemCount: tasks.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) {
final task = tasks[i];
return ListTile(
leading: _priorityIcon(task.priority),
title: Text(task.title),
subtitle: task.dueDate != null
? Text('Due: ${task.dueDate!.toLocal().toString().substring(0, 10)}')
: null,
onTap: () => context.push(
Routes.taskEdit.replaceFirst(':id', '${task.id}'),
),
);
},
);
}
Widget _priorityIcon(TaskPriority p) {
final color = switch (p) {
TaskPriority.high => Colors.red,
TaskPriority.medium => Colors.orange,
TaskPriority.low => Colors.green,
TaskPriority.none => Colors.grey,
};
return Icon(Icons.flag, color: color);
}
}