diff --git a/lib/app.dart b/lib/app.dart index 4d7d702..eb6d472 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -17,6 +17,7 @@ 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/projects/project_list_screen.dart'; import 'screens/settings/settings_screen.dart'; import 'screens/setup/setup_screen.dart'; import 'screens/splash/splash_screen.dart'; @@ -118,6 +119,10 @@ final routerProvider = Provider((ref) { path: Routes.tasks, builder: (_, _) => const TasksListScreen(), ), + GoRoute( + path: Routes.projects, + builder: (_, _) => const ProjectListScreen(), + ), GoRoute( path: Routes.conversations, builder: (_, _) => const ConversationsListScreen(), @@ -137,7 +142,7 @@ class _Shell extends ConsumerStatefulWidget { } class _ShellState extends ConsumerState<_Shell> { - static const _tabs = [Routes.notes, Routes.tasks, Routes.conversations]; + static const _tabs = [Routes.notes, Routes.tasks, Routes.projects, Routes.conversations]; @override void initState() { @@ -253,6 +258,11 @@ class _ShellState extends ConsumerState<_Shell> { selectedIcon: Icon(Icons.check_box), label: Text('Tasks'), ), + NavigationRailDestination( + icon: Icon(Icons.folder_outlined), + selectedIcon: Icon(Icons.folder), + label: Text('Projects'), + ), NavigationRailDestination( icon: Icon(Icons.chat_bubble_outline), selectedIcon: Icon(Icons.chat_bubble), @@ -284,6 +294,7 @@ class _ShellState extends ConsumerState<_Shell> { destinations: const [ NavigationDestination(icon: Icon(Icons.note), label: 'Notes'), NavigationDestination(icon: Icon(Icons.check_box), label: 'Tasks'), + NavigationDestination(icon: Icon(Icons.folder), label: 'Projects'), NavigationDestination( icon: Icon(Icons.chat_bubble), label: 'Chat'), ], diff --git a/lib/core/constants.dart b/lib/core/constants.dart index 104ea81..8b7b568 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -9,6 +9,7 @@ abstract class Routes { static const tasks = '/tasks'; static const taskNew = '/tasks/new'; static const taskEdit = '/tasks/:id/edit'; + static const projects = '/projects'; static const conversations = '/chat'; static const chat = '/chat/:id'; static const quickCapture = '/quick-capture'; diff --git a/lib/data/api/notes_api.dart b/lib/data/api/notes_api.dart index 9451045..048cdf7 100644 --- a/lib/data/api/notes_api.dart +++ b/lib/data/api/notes_api.dart @@ -27,11 +27,18 @@ class NotesApi { } } - Future create(String title, String body) async { + Future create( + String title, + String body, { + List tags = const [], + int? projectId, + }) async { try { final response = await _dio.post('/api/notes', data: { 'title': title, 'body': body, + 'tags': tags, + if (projectId != null) 'project_id': projectId, }); return Note.fromJson(response.data as Map); } on DioException catch (e) { @@ -39,11 +46,20 @@ class NotesApi { } } - Future update(int id, String title, String body) async { + Future update( + int id, + String title, + String body, { + List tags = const [], + int? projectId, + bool clearProject = false, + }) async { try { final response = await _dio.put('/api/notes/$id', data: { 'title': title, 'body': body, + 'tags': tags, + if (clearProject) 'project_id': null else if (projectId != null) 'project_id': projectId, }); return Note.fromJson(response.data as Map); } on DioException catch (e) { diff --git a/lib/data/api/projects_api.dart b/lib/data/api/projects_api.dart new file mode 100644 index 0000000..c28dbd4 --- /dev/null +++ b/lib/data/api/projects_api.dart @@ -0,0 +1,71 @@ +import 'package:dio/dio.dart'; + +import '../models/project.dart'; +import 'api_client.dart'; + +class ProjectsApi { + final Dio _dio; + const ProjectsApi(this._dio); + + Future> getAll({String? status}) async { + try { + final response = await _dio.get( + '/api/projects', + queryParameters: status != null ? {'status': status} : null, + ); + final data = response.data as Map; + final list = data['projects'] as List; + return list + .map((e) => Project.fromJson(e as Map)) + .toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future getOne(int id) async { + try { + final response = await _dio.get('/api/projects/$id'); + return Project.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future create({ + required String title, + String? description, + String? goal, + String? color, + }) async { + try { + final response = await _dio.post('/api/projects', data: { + 'title': title, + if (description != null && description.isNotEmpty) + 'description': description, + if (goal != null && goal.isNotEmpty) 'goal': goal, + if (color != null) 'color': color, + }); + return Project.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future update(int id, Map fields) async { + try { + final response = await _dio.patch('/api/projects/$id', data: fields); + return Project.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future delete(int id) async { + try { + await _dio.delete('/api/projects/$id'); + } on DioException catch (e) { + throw dioToApp(e); + } + } +} diff --git a/lib/data/api/tasks_api.dart b/lib/data/api/tasks_api.dart index e9ec1fe..76889db 100644 --- a/lib/data/api/tasks_api.dart +++ b/lib/data/api/tasks_api.dart @@ -33,6 +33,7 @@ class TasksApi { required TaskStatus status, required TaskPriority priority, DateTime? dueDate, + int? projectId, }) async { try { final response = await _dio.post('/api/tasks', data: { @@ -41,6 +42,7 @@ class TasksApi { 'status': status.value, 'priority': priority.value, 'due_date': dueDate?.toIso8601String(), + if (projectId != null) 'project_id': projectId, }); return Task.fromJson(response.data as Map); } on DioException catch (e) { diff --git a/lib/data/models/note.dart b/lib/data/models/note.dart index 647d326..4f36379 100644 --- a/lib/data/models/note.dart +++ b/lib/data/models/note.dart @@ -2,6 +2,9 @@ class Note { final int id; final String title; final String body; + final List tags; + final int? projectId; + final int? milestoneId; final DateTime createdAt; final DateTime updatedAt; @@ -9,6 +12,9 @@ class Note { required this.id, required this.title, required this.body, + required this.tags, + this.projectId, + this.milestoneId, required this.createdAt, required this.updatedAt, }); @@ -17,6 +23,12 @@ class Note { id: json['id'] as int, title: json['title'] as String? ?? '', body: json['body'] as String? ?? '', + tags: (json['tags'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], + projectId: json['project_id'] as int?, + milestoneId: json['milestone_id'] as int?, createdAt: DateTime.parse(json['created_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String), ); @@ -24,13 +36,32 @@ class Note { Map toJson() => { 'title': title, 'body': body, + 'tags': tags, + 'project_id': projectId, + 'milestone_id': milestoneId, }; - Note copyWith({String? title, String? body}) => Note( + Note copyWith({ + String? title, + String? body, + List? tags, + Object? projectId = _undefined, + Object? milestoneId = _undefined, + }) => + Note( id: id, title: title ?? this.title, body: body ?? this.body, + tags: tags ?? this.tags, + projectId: identical(projectId, _undefined) + ? this.projectId + : projectId as int?, + milestoneId: identical(milestoneId, _undefined) + ? this.milestoneId + : milestoneId as int?, createdAt: createdAt, updatedAt: updatedAt, ); + + static const _undefined = Object(); } diff --git a/lib/data/models/project.dart b/lib/data/models/project.dart new file mode 100644 index 0000000..e66f002 --- /dev/null +++ b/lib/data/models/project.dart @@ -0,0 +1,40 @@ +class Project { + final int id; + final String title; + final String? description; + final String? goal; + final String status; // active | completed | archived + final String? color; + final DateTime createdAt; + final DateTime updatedAt; + + const Project({ + required this.id, + required this.title, + this.description, + this.goal, + required this.status, + this.color, + required this.createdAt, + required this.updatedAt, + }); + + factory Project.fromJson(Map json) => Project( + id: json['id'] as int, + title: json['title'] as String? ?? '', + description: json['description'] as String?, + goal: json['goal'] as String?, + status: json['status'] as String? ?? 'active', + color: json['color'] as String?, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + ); + + Map toJson() => { + 'title': title, + 'description': description, + 'goal': goal, + 'status': status, + 'color': color, + }; +} diff --git a/lib/data/models/task.dart b/lib/data/models/task.dart index 67d90e5..31a6ac0 100644 --- a/lib/data/models/task.dart +++ b/lib/data/models/task.dart @@ -52,6 +52,9 @@ class Task { final TaskStatus status; final TaskPriority priority; final DateTime? dueDate; + final int? projectId; + final int? milestoneId; + final int? parentId; final DateTime createdAt; final DateTime updatedAt; @@ -62,6 +65,9 @@ class Task { required this.status, required this.priority, this.dueDate, + this.projectId, + this.milestoneId, + this.parentId, required this.createdAt, required this.updatedAt, }); @@ -75,6 +81,9 @@ class Task { dueDate: json['due_date'] != null ? DateTime.parse(json['due_date'] as String) : null, + projectId: json['project_id'] as int?, + milestoneId: json['milestone_id'] as int?, + parentId: json['parent_id'] as int?, createdAt: DateTime.parse(json['created_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String), ); @@ -85,6 +94,9 @@ class Task { 'status': status.value, 'priority': priority.value, 'due_date': dueDate?.toIso8601String(), + 'project_id': projectId, + 'milestone_id': milestoneId, + 'parent_id': parentId, }; Task copyWith({ @@ -93,6 +105,9 @@ class Task { TaskStatus? status, TaskPriority? priority, DateTime? dueDate, + Object? projectId = _undefined, + Object? milestoneId = _undefined, + Object? parentId = _undefined, }) => Task( id: id, @@ -101,7 +116,18 @@ class Task { status: status ?? this.status, priority: priority ?? this.priority, dueDate: dueDate ?? this.dueDate, + projectId: identical(projectId, _undefined) + ? this.projectId + : projectId as int?, + milestoneId: identical(milestoneId, _undefined) + ? this.milestoneId + : milestoneId as int?, + parentId: identical(parentId, _undefined) + ? this.parentId + : parentId as int?, createdAt: createdAt, updatedAt: updatedAt, ); + + static const _undefined = Object(); } diff --git a/lib/data/repositories/notes_repository.dart b/lib/data/repositories/notes_repository.dart index e09b90d..6fb1b44 100644 --- a/lib/data/repositories/notes_repository.dart +++ b/lib/data/repositories/notes_repository.dart @@ -7,9 +7,25 @@ class NotesRepository { Future> getAll() => _api.getAll(); Future getOne(int id) => _api.getOne(id); - Future create(String title, String body) => - _api.create(title, body); - Future update(int id, String title, String body) => - _api.update(id, title, body); + + Future create( + String title, + String body, { + List tags = const [], + int? projectId, + }) => + _api.create(title, body, tags: tags, projectId: projectId); + + Future update( + int id, + String title, + String body, { + List tags = const [], + int? projectId, + bool clearProject = false, + }) => + _api.update(id, title, body, + tags: tags, projectId: projectId, clearProject: clearProject); + Future delete(int id) => _api.delete(id); } diff --git a/lib/data/repositories/projects_repository.dart b/lib/data/repositories/projects_repository.dart new file mode 100644 index 0000000..a9a0cdd --- /dev/null +++ b/lib/data/repositories/projects_repository.dart @@ -0,0 +1,21 @@ +import '../api/projects_api.dart'; +import '../models/project.dart'; + +class ProjectsRepository { + final ProjectsApi _api; + const ProjectsRepository(this._api); + + Future> getAll({String? status}) => _api.getAll(status: status); + Future getOne(int id) => _api.getOne(id); + Future create({ + required String title, + String? description, + String? goal, + String? color, + }) => + _api.create( + title: title, description: description, goal: goal, color: color); + Future update(int id, Map fields) => + _api.update(id, fields); + Future delete(int id) => _api.delete(id); +} diff --git a/lib/data/repositories/tasks_repository.dart b/lib/data/repositories/tasks_repository.dart index c4bba4b..6f04534 100644 --- a/lib/data/repositories/tasks_repository.dart +++ b/lib/data/repositories/tasks_repository.dart @@ -14,6 +14,7 @@ class TasksRepository { required TaskStatus status, required TaskPriority priority, DateTime? dueDate, + int? projectId, }) => _api.create( title: title, @@ -21,6 +22,7 @@ class TasksRepository { status: status, priority: priority, dueDate: dueDate, + projectId: projectId, ); Future update(int id, Map fields) => diff --git a/lib/providers/api_client_provider.dart b/lib/providers/api_client_provider.dart index 2c416c3..fe9ce0b 100644 --- a/lib/providers/api_client_provider.dart +++ b/lib/providers/api_client_provider.dart @@ -6,11 +6,13 @@ 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/projects_api.dart'; import '../data/api/quick_capture_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/projects_repository.dart'; import '../data/repositories/tasks_repository.dart'; import 'settings_provider.dart'; @@ -45,6 +47,10 @@ final quickCaptureApiProvider = Provider((ref) { return QuickCaptureApi(ref.watch(dioProvider)); }); +final projectsApiProvider = Provider((ref) { + return ProjectsApi(ref.watch(dioProvider)); +}); + final authRepositoryProvider = Provider((ref) { return AuthRepository(ref.watch(authApiProvider)); }); @@ -60,3 +66,7 @@ final tasksRepositoryProvider = Provider((ref) { final chatRepositoryProvider = Provider((ref) { return ChatRepository(ref.watch(chatApiProvider)); }); + +final projectsRepositoryProvider = Provider((ref) { + return ProjectsRepository(ref.watch(projectsApiProvider)); +}); diff --git a/lib/providers/notes_provider.dart b/lib/providers/notes_provider.dart index 106c571..532f241 100644 --- a/lib/providers/notes_provider.dart +++ b/lib/providers/notes_provider.dart @@ -12,16 +12,35 @@ class NotesNotifier extends AsyncNotifier> { return ref.watch(notesRepositoryProvider).getAll(); } - Future create(String title, String body) async { - final note = - await ref.read(notesRepositoryProvider).create(title, body); + Future create( + String title, + String body, { + List tags = const [], + int? projectId, + }) async { + final note = await ref + .read(notesRepositoryProvider) + .create(title, body, tags: tags, projectId: projectId); state = AsyncData([...state.valueOrNull ?? [], note]); return note; } - Future updateNote(int id, String title, String body) async { - final updated = - await ref.read(notesRepositoryProvider).update(id, title, body); + Future updateNote( + int id, + String title, + String body, { + List tags = const [], + int? projectId, + bool clearProject = false, + }) async { + final updated = await ref.read(notesRepositoryProvider).update( + id, + title, + body, + tags: tags, + projectId: projectId, + clearProject: clearProject, + ); state = AsyncData([ for (final n in state.valueOrNull ?? []) if (n.id == id) updated else n, diff --git a/lib/providers/projects_provider.dart b/lib/providers/projects_provider.dart new file mode 100644 index 0000000..eac052b --- /dev/null +++ b/lib/providers/projects_provider.dart @@ -0,0 +1,49 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/models/project.dart'; +import 'api_client_provider.dart'; + +final projectsProvider = + AsyncNotifierProvider>( + ProjectsNotifier.new); + +class ProjectsNotifier extends AsyncNotifier> { + @override + Future> build() async { + return ref.watch(projectsRepositoryProvider).getAll(); + } + + Future create({ + required String title, + String? description, + String? goal, + String? color, + }) async { + final project = await ref.read(projectsRepositoryProvider).create( + title: title, + description: description, + goal: goal, + color: color, + ); + state = AsyncData([...state.valueOrNull ?? [], project]); + return project; + } + + Future updateProject(int id, Map fields) async { + final updated = + await ref.read(projectsRepositoryProvider).update(id, fields); + state = AsyncData([ + for (final p in state.valueOrNull ?? []) + if (p.id == id) updated else p, + ]); + return updated; + } + + Future delete(int id) async { + await ref.read(projectsRepositoryProvider).delete(id); + state = AsyncData([ + for (final p in state.valueOrNull ?? []) + if (p.id != id) p, + ]); + } +} diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index 00b476f..29b9855 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -18,6 +18,7 @@ class TasksNotifier extends AsyncNotifier> { required TaskStatus status, required TaskPriority priority, DateTime? dueDate, + int? projectId, }) async { final task = await ref.read(tasksRepositoryProvider).create( title: title, @@ -25,6 +26,7 @@ class TasksNotifier extends AsyncNotifier> { status: status, priority: priority, dueDate: dueDate, + projectId: projectId, ); state = AsyncData([...state.valueOrNull ?? [], task]); return task; diff --git a/lib/screens/notes/note_edit_screen.dart b/lib/screens/notes/note_edit_screen.dart index 3dfc203..0e9eefc 100644 --- a/lib/screens/notes/note_edit_screen.dart +++ b/lib/screens/notes/note_edit_screen.dart @@ -7,6 +7,7 @@ import '../../core/exceptions.dart'; import '../../core/wikilink_syntax.dart'; import '../../providers/api_client_provider.dart'; import '../../providers/notes_provider.dart'; +import '../../widgets/project_selector.dart'; class NoteEditScreen extends ConsumerStatefulWidget { final int? noteId; @@ -19,10 +20,12 @@ class NoteEditScreen extends ConsumerStatefulWidget { class _NoteEditScreenState extends ConsumerState { final _titleController = TextEditingController(); final _contentController = TextEditingController(); + final _tagController = TextEditingController(); + List _tags = []; + int? _projectId; bool _preview = false; bool _saving = false; - // Future is created once in initState so FutureBuilder never restarts it. late final Future _initFuture; @override @@ -36,6 +39,7 @@ class _NoteEditScreenState extends ConsumerState { void dispose() { _titleController.dispose(); _contentController.dispose(); + _tagController.dispose(); super.dispose(); } @@ -44,6 +48,24 @@ class _NoteEditScreenState extends ConsumerState { await ref.read(notesRepositoryProvider).getOne(widget.noteId!); _titleController.text = note.title; _contentController.text = note.body; + _tags = List.from(note.tags); + _projectId = note.projectId; + } + + void _addTag(String raw) { + final tag = raw.trim().replaceAll(',', '').toLowerCase(); + if (tag.isEmpty || _tags.contains(tag)) { + _tagController.clear(); + return; + } + setState(() { + _tags = [..._tags, tag]; + _tagController.clear(); + }); + } + + void _removeTag(String tag) { + setState(() => _tags = _tags.where((t) => t != tag).toList()); } Future _delete() async { @@ -81,12 +103,22 @@ class _NoteEditScreenState extends ConsumerState { setState(() => _saving = true); try { if (widget.noteId == null) { - await ref.read(notesProvider.notifier).create(title, body); + await ref.read(notesProvider.notifier).create( + title, + body, + tags: _tags, + projectId: _projectId, + ); if (mounted) context.pop(); } else { - await ref - .read(notesProvider.notifier) - .updateNote(widget.noteId!, title, body); + await ref.read(notesProvider.notifier).updateNote( + widget.noteId!, + title, + body, + tags: _tags, + projectId: _projectId, + clearProject: _projectId == null, + ); if (mounted) context.pop(); } } on AppException catch (e) { @@ -147,7 +179,23 @@ class _NoteEditScreenState extends ConsumerState { textInputAction: TextInputAction.next, ), ), - const Divider(), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: _TagInput( + tags: _tags, + controller: _tagController, + onAdd: _addTag, + onRemove: _removeTag, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: ProjectSelector( + value: _projectId, + onChanged: (id) => setState(() => _projectId = id), + ), + ), + const Divider(height: 1), Expanded( child: _preview ? Markdown( @@ -177,3 +225,57 @@ class _NoteEditScreenState extends ConsumerState { ); } } + +class _TagInput extends StatelessWidget { + final List tags; + final TextEditingController controller; + final ValueChanged onAdd; + final ValueChanged onRemove; + + const _TagInput({ + required this.tags, + required this.controller, + required this.onAdd, + required this.onRemove, + }); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + ...tags.map( + (tag) => Chip( + label: Text('#$tag', style: const TextStyle(fontSize: 12)), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.symmetric(horizontal: 4), + deleteIcon: const Icon(Icons.close, size: 14), + onDeleted: () => onRemove(tag), + ), + ), + SizedBox( + width: 120, + child: TextField( + controller: controller, + decoration: const InputDecoration( + hintText: 'Add tag…', + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.symmetric(vertical: 4), + ), + style: const TextStyle(fontSize: 13), + textInputAction: TextInputAction.done, + onSubmitted: onAdd, + onChanged: (v) { + if (v.endsWith(',') || v.endsWith(' ')) { + onAdd(v.replaceAll(RegExp(r'[, ]+$'), '')); + } + }, + ), + ), + ], + ); + } +} diff --git a/lib/screens/projects/project_list_screen.dart b/lib/screens/projects/project_list_screen.dart new file mode 100644 index 0000000..71eaaa0 --- /dev/null +++ b/lib/screens/projects/project_list_screen.dart @@ -0,0 +1,316 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/exceptions.dart'; +import '../../data/models/project.dart'; +import '../../providers/projects_provider.dart'; + +class ProjectListScreen extends ConsumerWidget { + const ProjectListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final projectsAsync = ref.watch(projectsProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Projects')), + floatingActionButton: FloatingActionButton( + onPressed: () => _showCreateDialog(context, ref), + tooltip: 'New project', + child: const Icon(Icons.add), + ), + body: projectsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (projects) { + if (projects.isEmpty) { + return const Center( + child: Text( + 'No projects yet.\nTap + to create one.', + textAlign: TextAlign.center, + ), + ); + } + final active = + projects.where((p) => p.status == 'active').toList(); + final other = + projects.where((p) => p.status != 'active').toList(); + return ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + if (active.isNotEmpty) ...[ + _SectionHeader(title: 'Active (${active.length})'), + ...active.map((p) => _ProjectTile(project: p)), + ], + if (other.isNotEmpty) ...[ + _SectionHeader(title: 'Other'), + ...other.map((p) => _ProjectTile(project: p)), + ], + ], + ); + }, + ), + ); + } + + void _showCreateDialog(BuildContext context, WidgetRef ref) { + showDialog( + context: context, + builder: (dialogContext) => _CreateProjectDialog( + onCreate: (title, description, goal) async { + try { + await ref + .read(projectsProvider.notifier) + .create(title: title, description: description, goal: goal); + } on AppException catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.message)), + ); + } + } + }, + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + final String title; + const _SectionHeader({required this.title}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + title, + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith(color: Theme.of(context).colorScheme.primary), + ), + ); + } +} + +class _ProjectTile extends ConsumerWidget { + final Project project; + const _ProjectTile({required this.project}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final statusColor = switch (project.status) { + 'completed' => Colors.green, + 'archived' => Colors.grey, + _ => Theme.of(context).colorScheme.primary, + }; + + return ListTile( + leading: CircleAvatar( + backgroundColor: statusColor.withValues(alpha: 0.15), + child: Icon(Icons.folder_outlined, color: statusColor, size: 20), + ), + title: Text(project.title), + subtitle: project.description != null && project.description!.isNotEmpty + ? Text( + project.description!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : null, + trailing: _StatusChip(status: project.status), + onLongPress: () => _showOptions(context, ref), + ); + } + + void _showOptions(BuildContext context, WidgetRef ref) { + showModalBottomSheet( + context: context, + builder: (_) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.check_circle_outline), + title: const Text('Mark completed'), + onTap: () async { + Navigator.pop(context); + await ref.read(projectsProvider.notifier).updateProject( + project.id, + {'status': 'completed'}, + ); + }, + ), + ListTile( + leading: const Icon(Icons.archive_outlined), + title: const Text('Archive'), + onTap: () async { + Navigator.pop(context); + await ref.read(projectsProvider.notifier).updateProject( + project.id, + {'status': 'archived'}, + ); + }, + ), + ListTile( + leading: Icon(Icons.delete_outline, + color: Theme.of(context).colorScheme.error), + title: Text('Delete', + style: TextStyle( + color: Theme.of(context).colorScheme.error)), + onTap: () async { + Navigator.pop(context); + final confirm = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Delete project?'), + content: Text( + 'Notes and tasks will be unlinked, not deleted.'), + actions: [ + TextButton( + onPressed: () => + Navigator.pop(dialogContext, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => + Navigator.pop(dialogContext, true), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirm == true && context.mounted) { + await ref + .read(projectsProvider.notifier) + .delete(project.id); + } + }, + ), + ], + ), + ), + ); + } +} + +class _StatusChip extends StatelessWidget { + final String status; + const _StatusChip({required this.status}); + + @override + Widget build(BuildContext context) { + final (label, color) = switch (status) { + 'completed' => ('Done', Colors.green), + 'archived' => ('Archived', Colors.grey), + _ => ('Active', Theme.of(context).colorScheme.primary), + }; + return Chip( + label: Text(label, style: const TextStyle(fontSize: 11)), + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + side: BorderSide(color: color.withValues(alpha: 0.4)), + backgroundColor: color.withValues(alpha: 0.1), + labelStyle: TextStyle(color: color), + ); + } +} + +class _CreateProjectDialog extends StatefulWidget { + final Future Function(String title, String? description, String? goal) + onCreate; + + const _CreateProjectDialog({required this.onCreate}); + + @override + State<_CreateProjectDialog> createState() => _CreateProjectDialogState(); +} + +class _CreateProjectDialogState extends State<_CreateProjectDialog> { + final _titleController = TextEditingController(); + final _descController = TextEditingController(); + final _goalController = TextEditingController(); + bool _saving = false; + + @override + void dispose() { + _titleController.dispose(); + _descController.dispose(); + _goalController.dispose(); + super.dispose(); + } + + Future _submit() async { + final title = _titleController.text.trim(); + if (title.isEmpty) return; + setState(() => _saving = true); + try { + await widget.onCreate( + title, + _descController.text.trim().isEmpty ? null : _descController.text.trim(), + _goalController.text.trim().isEmpty ? null : _goalController.text.trim(), + ); + if (mounted) Navigator.pop(context); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('New Project'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _titleController, + decoration: const InputDecoration( + labelText: 'Title', + border: OutlineInputBorder(), + ), + autofocus: true, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _descController, + decoration: const InputDecoration( + labelText: 'Description (optional)', + border: OutlineInputBorder(), + ), + maxLines: 2, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _goalController, + decoration: const InputDecoration( + labelText: 'Goal (optional)', + border: OutlineInputBorder(), + ), + textInputAction: TextInputAction.done, + onSubmitted: (_) => _submit(), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _saving ? null : _submit, + child: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Create'), + ), + ], + ); + } +} diff --git a/lib/screens/tasks/task_edit_screen.dart b/lib/screens/tasks/task_edit_screen.dart index de7a62d..0e42f4f 100644 --- a/lib/screens/tasks/task_edit_screen.dart +++ b/lib/screens/tasks/task_edit_screen.dart @@ -6,6 +6,7 @@ import '../../core/exceptions.dart'; import '../../data/models/task.dart'; import '../../providers/api_client_provider.dart'; import '../../providers/tasks_provider.dart'; +import '../../widgets/project_selector.dart'; class TaskEditScreen extends ConsumerStatefulWidget { final int? taskId; @@ -22,6 +23,7 @@ class _TaskEditScreenState extends ConsumerState { TaskStatus _status = TaskStatus.todo; TaskPriority _priority = TaskPriority.medium; DateTime? _dueDate; + int? _projectId; bool _saving = false; // Future is created once in initState so FutureBuilder never restarts it. @@ -49,6 +51,7 @@ class _TaskEditScreenState extends ConsumerState { _status = task.status; _priority = task.priority; _dueDate = task.dueDate; + _projectId = task.projectId; } Future _save() async { @@ -64,16 +67,18 @@ class _TaskEditScreenState extends ConsumerState { status: _status, priority: _priority, dueDate: _dueDate, + projectId: _projectId, ); } else { await ref.read(tasksProvider.notifier).updateTask(widget.taskId!, { 'title': _titleController.text.trim(), - 'description': _descController.text.trim().isEmpty + 'body': _descController.text.trim().isEmpty ? null : _descController.text.trim(), 'status': _status.value, 'priority': _priority.value, 'due_date': _dueDate?.toIso8601String(), + 'project_id': _projectId, }); } if (mounted) context.pop(); @@ -216,6 +221,11 @@ class _TaskEditScreenState extends ConsumerState { : null, onTap: _pickDate, ), + const SizedBox(height: 16), + ProjectSelector( + value: _projectId, + onChanged: (id) => setState(() => _projectId = id), + ), ], ), ), diff --git a/lib/widgets/project_selector.dart b/lib/widgets/project_selector.dart new file mode 100644 index 0000000..49ec850 --- /dev/null +++ b/lib/widgets/project_selector.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/projects_provider.dart'; + +/// Dropdown for picking a project. Pass [value] as the current project id +/// (null = no project) and [onChanged] to receive updates. +class ProjectSelector extends ConsumerWidget { + final int? value; + final ValueChanged onChanged; + final InputDecoration? decoration; + + const ProjectSelector({ + super.key, + required this.value, + required this.onChanged, + this.decoration, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final projectsAsync = ref.watch(projectsProvider); + + return projectsAsync.when( + loading: () => const LinearProgressIndicator(), + error: (_, __) => const SizedBox.shrink(), + data: (projects) { + final active = + projects.where((p) => p.status == 'active').toList(); + + return DropdownButtonFormField( + value: value, + decoration: decoration ?? + const InputDecoration( + labelText: 'Project (optional)', + border: OutlineInputBorder(), + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('No project'), + ), + ...active.map( + (p) => DropdownMenuItem( + value: p.id, + child: Text(p.title, overflow: TextOverflow.ellipsis), + ), + ), + ], + onChanged: onChanged, + ); + }, + ); + } +}