Add Projects feature; sync Note/Task models with backend additions
## 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>
This commit is contained in:
+12
-1
@@ -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<GoRouter>((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'),
|
||||
],
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -27,11 +27,18 @@ class NotesApi {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Note> create(String title, String body) async {
|
||||
Future<Note> create(
|
||||
String title,
|
||||
String body, {
|
||||
List<String> 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<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
@@ -39,11 +46,20 @@ class NotesApi {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Note> update(int id, String title, String body) async {
|
||||
Future<Note> update(
|
||||
int id,
|
||||
String title,
|
||||
String body, {
|
||||
List<String> 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<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
|
||||
@@ -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<List<Project>> getAll({String? status}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/api/projects',
|
||||
queryParameters: status != null ? {'status': status} : null,
|
||||
);
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final list = data['projects'] as List<dynamic>;
|
||||
return list
|
||||
.map((e) => Project.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Project> getOne(int id) async {
|
||||
try {
|
||||
final response = await _dio.get('/api/projects/$id');
|
||||
return Project.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Project> 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<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Project> update(int id, Map<String, dynamic> fields) async {
|
||||
try {
|
||||
final response = await _dio.patch('/api/projects/$id', data: fields);
|
||||
return Project.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
try {
|
||||
await _dio.delete('/api/projects/$id');
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
|
||||
@@ -2,6 +2,9 @@ class Note {
|
||||
final int id;
|
||||
final String title;
|
||||
final String body;
|
||||
final List<String> 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<dynamic>?)
|
||||
?.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<String, dynamic> 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<String>? 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();
|
||||
}
|
||||
|
||||
@@ -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<String, dynamic> 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<String, dynamic> toJson() => {
|
||||
'title': title,
|
||||
'description': description,
|
||||
'goal': goal,
|
||||
'status': status,
|
||||
'color': color,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -7,9 +7,25 @@ class NotesRepository {
|
||||
|
||||
Future<List<Note>> getAll() => _api.getAll();
|
||||
Future<Note> getOne(int id) => _api.getOne(id);
|
||||
Future<Note> create(String title, String body) =>
|
||||
_api.create(title, body);
|
||||
Future<Note> update(int id, String title, String body) =>
|
||||
_api.update(id, title, body);
|
||||
|
||||
Future<Note> create(
|
||||
String title,
|
||||
String body, {
|
||||
List<String> tags = const [],
|
||||
int? projectId,
|
||||
}) =>
|
||||
_api.create(title, body, tags: tags, projectId: projectId);
|
||||
|
||||
Future<Note> update(
|
||||
int id,
|
||||
String title,
|
||||
String body, {
|
||||
List<String> tags = const [],
|
||||
int? projectId,
|
||||
bool clearProject = false,
|
||||
}) =>
|
||||
_api.update(id, title, body,
|
||||
tags: tags, projectId: projectId, clearProject: clearProject);
|
||||
|
||||
Future<void> delete(int id) => _api.delete(id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import '../api/projects_api.dart';
|
||||
import '../models/project.dart';
|
||||
|
||||
class ProjectsRepository {
|
||||
final ProjectsApi _api;
|
||||
const ProjectsRepository(this._api);
|
||||
|
||||
Future<List<Project>> getAll({String? status}) => _api.getAll(status: status);
|
||||
Future<Project> getOne(int id) => _api.getOne(id);
|
||||
Future<Project> create({
|
||||
required String title,
|
||||
String? description,
|
||||
String? goal,
|
||||
String? color,
|
||||
}) =>
|
||||
_api.create(
|
||||
title: title, description: description, goal: goal, color: color);
|
||||
Future<Project> update(int id, Map<String, dynamic> fields) =>
|
||||
_api.update(id, fields);
|
||||
Future<void> delete(int id) => _api.delete(id);
|
||||
}
|
||||
@@ -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<Task> update(int id, Map<String, dynamic> fields) =>
|
||||
|
||||
@@ -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<QuickCaptureApi>((ref) {
|
||||
return QuickCaptureApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final projectsApiProvider = Provider<ProjectsApi>((ref) {
|
||||
return ProjectsApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||
return AuthRepository(ref.watch(authApiProvider));
|
||||
});
|
||||
@@ -60,3 +66,7 @@ final tasksRepositoryProvider = Provider<TasksRepository>((ref) {
|
||||
final chatRepositoryProvider = Provider<ChatRepository>((ref) {
|
||||
return ChatRepository(ref.watch(chatApiProvider));
|
||||
});
|
||||
|
||||
final projectsRepositoryProvider = Provider<ProjectsRepository>((ref) {
|
||||
return ProjectsRepository(ref.watch(projectsApiProvider));
|
||||
});
|
||||
|
||||
@@ -12,16 +12,35 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
|
||||
return ref.watch(notesRepositoryProvider).getAll();
|
||||
}
|
||||
|
||||
Future<Note> create(String title, String body) async {
|
||||
final note =
|
||||
await ref.read(notesRepositoryProvider).create(title, body);
|
||||
Future<Note> create(
|
||||
String title,
|
||||
String body, {
|
||||
List<String> 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<Note> updateNote(int id, String title, String body) async {
|
||||
final updated =
|
||||
await ref.read(notesRepositoryProvider).update(id, title, body);
|
||||
Future<Note> updateNote(
|
||||
int id,
|
||||
String title,
|
||||
String body, {
|
||||
List<String> 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,
|
||||
|
||||
@@ -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, List<Project>>(
|
||||
ProjectsNotifier.new);
|
||||
|
||||
class ProjectsNotifier extends AsyncNotifier<List<Project>> {
|
||||
@override
|
||||
Future<List<Project>> build() async {
|
||||
return ref.watch(projectsRepositoryProvider).getAll();
|
||||
}
|
||||
|
||||
Future<Project> 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<Project> updateProject(int id, Map<String, dynamic> 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<void> delete(int id) async {
|
||||
await ref.read(projectsRepositoryProvider).delete(id);
|
||||
state = AsyncData([
|
||||
for (final p in state.valueOrNull ?? [])
|
||||
if (p.id != id) p,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
|
||||
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<List<Task>> {
|
||||
status: status,
|
||||
priority: priority,
|
||||
dueDate: dueDate,
|
||||
projectId: projectId,
|
||||
);
|
||||
state = AsyncData([...state.valueOrNull ?? [], task]);
|
||||
return task;
|
||||
|
||||
@@ -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<NoteEditScreen> {
|
||||
final _titleController = TextEditingController();
|
||||
final _contentController = TextEditingController();
|
||||
final _tagController = TextEditingController();
|
||||
List<String> _tags = [];
|
||||
int? _projectId;
|
||||
bool _preview = false;
|
||||
bool _saving = false;
|
||||
|
||||
// Future is created once in initState so FutureBuilder never restarts it.
|
||||
late final Future<void> _initFuture;
|
||||
|
||||
@override
|
||||
@@ -36,6 +39,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_contentController.dispose();
|
||||
_tagController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -44,6 +48,24 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
await ref.read(notesRepositoryProvider).getOne(widget.noteId!);
|
||||
_titleController.text = note.title;
|
||||
_contentController.text = note.body;
|
||||
_tags = List<String>.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<void> _delete() async {
|
||||
@@ -81,12 +103,22 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
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<NoteEditScreen> {
|
||||
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<NoteEditScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TagInput extends StatelessWidget {
|
||||
final List<String> tags;
|
||||
final TextEditingController controller;
|
||||
final ValueChanged<String> onAdd;
|
||||
final ValueChanged<String> 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'[, ]+$'), ''));
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<TaskEditScreen> {
|
||||
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<TaskEditScreen> {
|
||||
_status = task.status;
|
||||
_priority = task.priority;
|
||||
_dueDate = task.dueDate;
|
||||
_projectId = task.projectId;
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
@@ -64,16 +67,18 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
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<TaskEditScreen> {
|
||||
: null,
|
||||
onTap: _pickDate,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ProjectSelector(
|
||||
value: _projectId,
|
||||
onChanged: (id) => setState(() => _projectId = id),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<int?> 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<int?>(
|
||||
value: value,
|
||||
decoration: decoration ??
|
||||
const InputDecoration(
|
||||
labelText: 'Project (optional)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
const DropdownMenuItem<int?>(
|
||||
value: null,
|
||||
child: Text('No project'),
|
||||
),
|
||||
...active.map(
|
||||
(p) => DropdownMenuItem<int?>(
|
||||
value: p.id,
|
||||
child: Text(p.title, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: onChanged,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user