diff --git a/lib/data/api/projects_api.dart b/lib/data/api/projects_api.dart index 41edf8a..548b23f 100644 --- a/lib/data/api/projects_api.dart +++ b/lib/data/api/projects_api.dart @@ -7,13 +7,17 @@ class ProjectsApi { final Dio _dio; const ProjectsApi(this._dio); - Future> getAll({String? status}) async { + Future> getAll({ + String? status, + String sort = 'updated_at', + String order = 'desc', + }) async { try { final response = await _dio.get( '/api/projects', queryParameters: { - 'sort': 'updated_at', - 'order': 'desc', + 'sort': sort, + 'order': order, if (status != null) 'status': status, }, ); diff --git a/lib/data/repositories/projects_repository.dart b/lib/data/repositories/projects_repository.dart index 24f52c2..ca93578 100644 --- a/lib/data/repositories/projects_repository.dart +++ b/lib/data/repositories/projects_repository.dart @@ -5,7 +5,12 @@ class ProjectsRepository { final ProjectsApi _api; const ProjectsRepository(this._api); - Future> getAll({String? status}) => _api.getAll(status: status); + Future> getAll({ + String? status, + String sort = 'updated_at', + String order = 'desc', + }) => + _api.getAll(status: status, sort: sort, order: order); Future getOne(int id) => _api.getOne(id); Future create({ required String title, diff --git a/lib/providers/projects_provider.dart b/lib/providers/projects_provider.dart index 5c9d940..d3981d5 100644 --- a/lib/providers/projects_provider.dart +++ b/lib/providers/projects_provider.dart @@ -10,7 +10,9 @@ final projectsProvider = class ProjectsNotifier extends AsyncNotifier> { @override Future> build() async { - return ref.watch(projectsRepositoryProvider).getAll(); + return ref + .watch(projectsRepositoryProvider) + .getAll(sort: 'updated_at', order: 'desc'); } Future create({ diff --git a/lib/screens/projects/project_edit_screen.dart b/lib/screens/projects/project_edit_screen.dart new file mode 100644 index 0000000..ca5f3e3 --- /dev/null +++ b/lib/screens/projects/project_edit_screen.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/exceptions.dart'; +import '../../providers/projects_provider.dart'; + +class ProjectEditScreen extends ConsumerStatefulWidget { + final int? projectId; + const ProjectEditScreen({super.key, this.projectId}); + + @override + ConsumerState createState() => _ProjectEditScreenState(); +} + +class _ProjectEditScreenState extends ConsumerState { + final _titleController = TextEditingController(); + final _descController = TextEditingController(); + final _goalController = TextEditingController(); + String _status = 'active'; + bool _saving = false; + late final Future _initFuture; + + @override + void initState() { + super.initState(); + _initFuture = + widget.projectId != null ? _loadExisting() : Future.value(); + } + + @override + void dispose() { + _titleController.dispose(); + _descController.dispose(); + _goalController.dispose(); + super.dispose(); + } + + Future _loadExisting() async { + final projects = ref.read(projectsProvider).value ?? []; + final project = + projects.where((p) => p.id == widget.projectId).firstOrNull; + if (project != null) { + _titleController.text = project.title; + _descController.text = project.description ?? ''; + _goalController.text = project.goal ?? ''; + _status = project.status; + } + } + + Future _save() async { + final title = _titleController.text.trim(); + if (title.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Title is required.'))); + return; + } + setState(() => _saving = true); + try { + if (widget.projectId == null) { + await ref.read(projectsProvider.notifier).create( + title: title, + description: _descController.text.trim().isEmpty + ? null + : _descController.text.trim(), + goal: _goalController.text.trim().isEmpty + ? null + : _goalController.text.trim(), + ); + } else { + await ref.read(projectsProvider.notifier).updateProject( + widget.projectId!, + { + 'title': title, + 'description': _descController.text.trim(), + 'goal': _goalController.text.trim(), + 'status': _status, + }, + ); + } + 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: _initFuture, + builder: (context, snapshot) => Scaffold( + appBar: AppBar( + title: Text( + widget.projectId == null ? 'New Project' : 'Edit Project'), + actions: [ + 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()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + TextField( + controller: _titleController, + decoration: const InputDecoration( + labelText: 'Title *', border: OutlineInputBorder()), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _descController, + decoration: const InputDecoration( + labelText: 'Description', + border: OutlineInputBorder()), + maxLines: 3, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _goalController, + decoration: const InputDecoration( + labelText: 'Goal', border: OutlineInputBorder()), + maxLines: 2, + textInputAction: TextInputAction.done, + ), + if (widget.projectId != null) ...[ + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _status, + decoration: const InputDecoration( + labelText: 'Status', + border: OutlineInputBorder()), + items: const [ + DropdownMenuItem( + value: 'active', child: Text('Active')), + DropdownMenuItem( + value: 'completed', child: Text('Completed')), + DropdownMenuItem( + value: 'archived', child: Text('Archived')), + ], + onChanged: (v) => setState(() => _status = v!), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/screens/projects/projects_screen.dart b/lib/screens/projects/projects_screen.dart new file mode 100644 index 0000000..a468ff0 --- /dev/null +++ b/lib/screens/projects/projects_screen.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../data/models/project.dart'; +import '../../providers/projects_provider.dart'; + +class ProjectsScreen extends ConsumerWidget { + const ProjectsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final projectsAsync = ref.watch(projectsProvider); + return Scaffold( + appBar: AppBar(title: const Text('Projects')), + body: projectsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (projects) => projects.isEmpty + ? const Center(child: Text('No projects yet.')) + : RefreshIndicator( + onRefresh: () async => ref.invalidate(projectsProvider), + child: ListView.separated( + itemCount: projects.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (_, i) => _ProjectCard(project: projects[i]), + ), + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () => context.push('/projects/new'), + tooltip: 'New project', + child: const Icon(Icons.add), + ), + ); + } +} + +class _ProjectCard extends StatelessWidget { + final Project project; + const _ProjectCard({required this.project}); + + Color _statusColor(BuildContext context) => switch (project.status) { + 'completed' => Colors.blue, + 'archived' => Colors.grey, + _ => Theme.of(context).colorScheme.primary, + }; + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(project.title), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (project.description?.isNotEmpty == true) + Text( + project.description!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (project.goal?.isNotEmpty == true) + Text( + project.goal!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(fontStyle: FontStyle.italic), + ), + ], + ), + trailing: Chip( + label: Text( + project.status, + style: TextStyle( + fontSize: 11, + color: _statusColor(context), + ), + ), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onTap: () => context.push('/projects/${project.id}/tasks'), + ); + } +}