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:
+193
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(() {}),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user