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:
@@ -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