fceae5529d
## New: Projects - Project model, API, repository, Riverpod provider - ProjectListScreen: active/archived sections, create dialog, long-press status/delete - ProjectSelector widget: DropdownButtonFormField for note + task editors - Projects tab added to shell (bottom nav + navigation rail, 4th position) - /projects route registered in GoRouter ShellRoute ## Updated: Note model - Added tags: List<String>, projectId: int?, milestoneId: int? - NotesApi.create/update pass tags and project_id - NotesRepository and NotesNotifier signatures updated - NoteEditScreen: chip-based tag input + ProjectSelector ## Updated: Task model - Added projectId: int?, milestoneId: int?, parentId: int? - TasksApi.create passes project_id; update payload includes project_id - TasksRepository and TasksNotifier signatures updated - TaskEditScreen: ProjectSelector added; project_id sent on save ## Provider fix - ProjectsNotifier.update renamed to updateProject to avoid conflict with AsyncNotifier.update(FutureOr<State> Function(State)) base method Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
317 lines
9.8 KiB
Dart
317 lines
9.8 KiB
Dart
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<void>(
|
|
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<void>(
|
|
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<bool>(
|
|
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<void> 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<void> _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'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|