Add milestone data layer and redesign Projects tab
Milestone data layer: - lib/data/models/milestone.dart: Milestone model with progress fields (total, completed, pct) from backend progress endpoint - lib/data/api/milestones_api.dart: CRUD client for /api/projects/:id/milestones - lib/data/repositories/milestones_repository.dart: thin repository wrapper - lib/providers/milestones_provider.dart: FutureProvider.family keyed by project ID — lazily fetched per project when expanded - api_client_provider.dart: registers milestonesApiProvider and milestonesRepositoryProvider Projects tab redesign (project_list_screen.dart): - Each project is now an ExpansionTile showing open task count in subtitle - Expanding a project fetches its milestones and groups tasks under milestone headers with inline progress bar (completed/total count) - Tasks show status icon, due date with overdue highlighting, priority dot - 'No milestone' section at bottom for unassigned tasks - Tapping a task navigates to the task editor - Long-press on project title opens archive/complete/delete bottom sheet - 'New task' button at bottom of each expanded project section Bump version to 26.03.02+1 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../models/milestone.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
class MilestonesApi {
|
||||||
|
final Dio _dio;
|
||||||
|
const MilestonesApi(this._dio);
|
||||||
|
|
||||||
|
Future<List<Milestone>> getAll(int projectId, {String? status}) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get(
|
||||||
|
'/api/projects/$projectId/milestones',
|
||||||
|
queryParameters: status != null ? {'status': status} : null,
|
||||||
|
);
|
||||||
|
final data = response.data as Map<String, dynamic>;
|
||||||
|
final list = data['milestones'] as List<dynamic>;
|
||||||
|
return list
|
||||||
|
.map((e) => Milestone.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Milestone> create(
|
||||||
|
int projectId, {
|
||||||
|
required String title,
|
||||||
|
String? description,
|
||||||
|
int orderIndex = 0,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.post(
|
||||||
|
'/api/projects/$projectId/milestones',
|
||||||
|
data: {
|
||||||
|
'title': title,
|
||||||
|
if (description != null && description.isNotEmpty)
|
||||||
|
'description': description,
|
||||||
|
'order_index': orderIndex,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return Milestone.fromJson(response.data as Map<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Milestone> update(
|
||||||
|
int projectId,
|
||||||
|
int milestoneId,
|
||||||
|
Map<String, dynamic> fields,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.patch(
|
||||||
|
'/api/projects/$projectId/milestones/$milestoneId',
|
||||||
|
data: fields,
|
||||||
|
);
|
||||||
|
return Milestone.fromJson(response.data as Map<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> delete(int projectId, int milestoneId) async {
|
||||||
|
try {
|
||||||
|
await _dio.delete('/api/projects/$projectId/milestones/$milestoneId');
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
class Milestone {
|
||||||
|
final int id;
|
||||||
|
final int projectId;
|
||||||
|
final String title;
|
||||||
|
final String? description;
|
||||||
|
final String status; // active | completed | archived
|
||||||
|
final int orderIndex;
|
||||||
|
final int total;
|
||||||
|
final int completed;
|
||||||
|
final double pct;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
const Milestone({
|
||||||
|
required this.id,
|
||||||
|
required this.projectId,
|
||||||
|
required this.title,
|
||||||
|
this.description,
|
||||||
|
required this.status,
|
||||||
|
required this.orderIndex,
|
||||||
|
required this.total,
|
||||||
|
required this.completed,
|
||||||
|
required this.pct,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Milestone.fromJson(Map<String, dynamic> json) => Milestone(
|
||||||
|
id: json['id'] as int,
|
||||||
|
projectId: json['project_id'] as int,
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
description: json['description'] as String?,
|
||||||
|
status: json['status'] as String? ?? 'active',
|
||||||
|
orderIndex: json['order_index'] as int? ?? 0,
|
||||||
|
total: json['total'] as int? ?? 0,
|
||||||
|
completed: json['completed'] as int? ?? 0,
|
||||||
|
pct: (json['pct'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import '../api/milestones_api.dart';
|
||||||
|
import '../models/milestone.dart';
|
||||||
|
|
||||||
|
class MilestonesRepository {
|
||||||
|
final MilestonesApi _api;
|
||||||
|
const MilestonesRepository(this._api);
|
||||||
|
|
||||||
|
Future<List<Milestone>> getAll(int projectId, {String? status}) =>
|
||||||
|
_api.getAll(projectId, status: status);
|
||||||
|
|
||||||
|
Future<Milestone> create(
|
||||||
|
int projectId, {
|
||||||
|
required String title,
|
||||||
|
String? description,
|
||||||
|
int orderIndex = 0,
|
||||||
|
}) =>
|
||||||
|
_api.create(projectId,
|
||||||
|
title: title, description: description, orderIndex: orderIndex);
|
||||||
|
|
||||||
|
Future<Milestone> update(
|
||||||
|
int projectId, int milestoneId, Map<String, dynamic> fields) =>
|
||||||
|
_api.update(projectId, milestoneId, fields);
|
||||||
|
|
||||||
|
Future<void> delete(int projectId, int milestoneId) =>
|
||||||
|
_api.delete(projectId, milestoneId);
|
||||||
|
}
|
||||||
@@ -5,12 +5,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../data/api/api_client.dart';
|
import '../data/api/api_client.dart';
|
||||||
import '../data/api/auth_api.dart';
|
import '../data/api/auth_api.dart';
|
||||||
import '../data/api/chat_api.dart';
|
import '../data/api/chat_api.dart';
|
||||||
|
import '../data/api/milestones_api.dart';
|
||||||
import '../data/api/notes_api.dart';
|
import '../data/api/notes_api.dart';
|
||||||
import '../data/api/projects_api.dart';
|
import '../data/api/projects_api.dart';
|
||||||
import '../data/api/quick_capture_api.dart';
|
import '../data/api/quick_capture_api.dart';
|
||||||
import '../data/api/tasks_api.dart';
|
import '../data/api/tasks_api.dart';
|
||||||
import '../data/repositories/auth_repository.dart';
|
import '../data/repositories/auth_repository.dart';
|
||||||
import '../data/repositories/chat_repository.dart';
|
import '../data/repositories/chat_repository.dart';
|
||||||
|
import '../data/repositories/milestones_repository.dart';
|
||||||
import '../data/repositories/notes_repository.dart';
|
import '../data/repositories/notes_repository.dart';
|
||||||
import '../data/repositories/projects_repository.dart';
|
import '../data/repositories/projects_repository.dart';
|
||||||
import '../data/repositories/tasks_repository.dart';
|
import '../data/repositories/tasks_repository.dart';
|
||||||
@@ -70,3 +72,11 @@ final chatRepositoryProvider = Provider<ChatRepository>((ref) {
|
|||||||
final projectsRepositoryProvider = Provider<ProjectsRepository>((ref) {
|
final projectsRepositoryProvider = Provider<ProjectsRepository>((ref) {
|
||||||
return ProjectsRepository(ref.watch(projectsApiProvider));
|
return ProjectsRepository(ref.watch(projectsApiProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final milestonesApiProvider = Provider<MilestonesApi>((ref) {
|
||||||
|
return MilestonesApi(ref.watch(dioProvider));
|
||||||
|
});
|
||||||
|
|
||||||
|
final milestonesRepositoryProvider = Provider<MilestonesRepository>((ref) {
|
||||||
|
return MilestonesRepository(ref.watch(milestonesApiProvider));
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../data/models/milestone.dart';
|
||||||
|
import 'api_client_provider.dart';
|
||||||
|
|
||||||
|
/// Fetches active milestones for a given project ID.
|
||||||
|
/// Keyed by projectId so each project gets its own cached list.
|
||||||
|
final projectMilestonesProvider =
|
||||||
|
FutureProvider.family<List<Milestone>, int>((ref, projectId) {
|
||||||
|
return ref.watch(milestonesRepositoryProvider).getAll(projectId);
|
||||||
|
});
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/constants.dart';
|
||||||
import '../../core/exceptions.dart';
|
import '../../core/exceptions.dart';
|
||||||
|
import '../../data/models/milestone.dart';
|
||||||
import '../../data/models/project.dart';
|
import '../../data/models/project.dart';
|
||||||
|
import '../../data/models/task.dart';
|
||||||
|
import '../../providers/milestones_provider.dart';
|
||||||
import '../../providers/projects_provider.dart';
|
import '../../providers/projects_provider.dart';
|
||||||
|
import '../../providers/tasks_provider.dart';
|
||||||
|
|
||||||
class ProjectListScreen extends ConsumerWidget {
|
class ProjectListScreen extends ConsumerWidget {
|
||||||
const ProjectListScreen({super.key});
|
const ProjectListScreen({super.key});
|
||||||
@@ -36,15 +42,15 @@ class ProjectListScreen extends ConsumerWidget {
|
|||||||
final other =
|
final other =
|
||||||
projects.where((p) => p.status != 'active').toList();
|
projects.where((p) => p.status != 'active').toList();
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.only(bottom: 88),
|
||||||
children: [
|
children: [
|
||||||
if (active.isNotEmpty) ...[
|
if (active.isNotEmpty) ...[
|
||||||
_SectionHeader(title: 'Active (${active.length})'),
|
_SectionHeader(title: 'Active (${active.length})'),
|
||||||
...active.map((p) => _ProjectTile(project: p)),
|
...active.map((p) => _ProjectExpansionTile(project: p)),
|
||||||
],
|
],
|
||||||
if (other.isNotEmpty) ...[
|
if (other.isNotEmpty) ...[
|
||||||
_SectionHeader(title: 'Other'),
|
_SectionHeader(title: 'Other'),
|
||||||
...other.map((p) => _ProjectTile(project: p)),
|
...other.map((p) => _ProjectExpansionTile(project: p)),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -75,6 +81,8 @@ class ProjectListScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Section header ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class _SectionHeader extends StatelessWidget {
|
class _SectionHeader extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
const _SectionHeader({required this.title});
|
const _SectionHeader({required this.title});
|
||||||
@@ -94,37 +102,28 @@ class _SectionHeader extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ProjectTile extends ConsumerWidget {
|
// ─── Project expansion tile ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _ProjectExpansionTile extends ConsumerStatefulWidget {
|
||||||
final Project project;
|
final Project project;
|
||||||
const _ProjectTile({required this.project});
|
const _ProjectExpansionTile({required this.project});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<_ProjectExpansionTile> createState() =>
|
||||||
final statusColor = switch (project.status) {
|
_ProjectExpansionTileState();
|
||||||
'completed' => Colors.green,
|
}
|
||||||
'archived' => Colors.grey,
|
|
||||||
_ => Theme.of(context).colorScheme.primary,
|
|
||||||
};
|
|
||||||
|
|
||||||
return ListTile(
|
class _ProjectExpansionTileState
|
||||||
leading: CircleAvatar(
|
extends ConsumerState<_ProjectExpansionTile> {
|
||||||
backgroundColor: statusColor.withValues(alpha: 0.15),
|
bool _expanded = false;
|
||||||
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) {
|
Color _statusColor(BuildContext context) => switch (widget.project.status) {
|
||||||
|
'completed' => Colors.green,
|
||||||
|
'archived' => Colors.grey,
|
||||||
|
_ => Theme.of(context).colorScheme.primary,
|
||||||
|
};
|
||||||
|
|
||||||
|
void _showOptions(BuildContext context) {
|
||||||
showModalBottomSheet<void>(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (_) => SafeArea(
|
builder: (_) => SafeArea(
|
||||||
@@ -137,7 +136,7 @@ class _ProjectTile extends ConsumerWidget {
|
|||||||
onTap: () async {
|
onTap: () async {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
await ref.read(projectsProvider.notifier).updateProject(
|
await ref.read(projectsProvider.notifier).updateProject(
|
||||||
project.id,
|
widget.project.id,
|
||||||
{'status': 'completed'},
|
{'status': 'completed'},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -148,7 +147,7 @@ class _ProjectTile extends ConsumerWidget {
|
|||||||
onTap: () async {
|
onTap: () async {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
await ref.read(projectsProvider.notifier).updateProject(
|
await ref.read(projectsProvider.notifier).updateProject(
|
||||||
project.id,
|
widget.project.id,
|
||||||
{'status': 'archived'},
|
{'status': 'archived'},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -165,7 +164,7 @@ class _ProjectTile extends ConsumerWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
title: const Text('Delete project?'),
|
title: const Text('Delete project?'),
|
||||||
content: Text(
|
content: const Text(
|
||||||
'Notes and tasks will be unlinked, not deleted.'),
|
'Notes and tasks will be unlinked, not deleted.'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -184,7 +183,7 @@ class _ProjectTile extends ConsumerWidget {
|
|||||||
if (confirm == true && context.mounted) {
|
if (confirm == true && context.mounted) {
|
||||||
await ref
|
await ref
|
||||||
.read(projectsProvider.notifier)
|
.read(projectsProvider.notifier)
|
||||||
.delete(project.id);
|
.delete(widget.project.id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -193,8 +192,322 @@ class _ProjectTile extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final statusColor = _statusColor(context);
|
||||||
|
final tasksAsync = ref.watch(tasksProvider);
|
||||||
|
final unfinished = tasksAsync.valueOrNull
|
||||||
|
?.where((t) =>
|
||||||
|
t.projectId == widget.project.id &&
|
||||||
|
t.status != TaskStatus.done)
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
|
||||||
|
final subtitle = unfinished.isEmpty
|
||||||
|
? (widget.project.description != null &&
|
||||||
|
widget.project.description!.isNotEmpty
|
||||||
|
? widget.project.description!
|
||||||
|
: null)
|
||||||
|
: '${unfinished.length} task${unfinished.length == 1 ? '' : 's'} in progress';
|
||||||
|
|
||||||
|
return ExpansionTile(
|
||||||
|
key: PageStorageKey('project-${widget.project.id}'),
|
||||||
|
initiallyExpanded: false,
|
||||||
|
onExpansionChanged: (v) => setState(() => _expanded = v),
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: statusColor.withValues(alpha: 0.15),
|
||||||
|
child: Icon(Icons.folder_outlined, color: statusColor, size: 20),
|
||||||
|
),
|
||||||
|
title: GestureDetector(
|
||||||
|
onLongPress: () => _showOptions(context),
|
||||||
|
child: Text(widget.project.title),
|
||||||
|
),
|
||||||
|
subtitle: subtitle != null
|
||||||
|
? Text(
|
||||||
|
subtitle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
trailing: _StatusChip(status: widget.project.status),
|
||||||
|
children: [
|
||||||
|
if (_expanded)
|
||||||
|
_ProjectTaskList(
|
||||||
|
project: widget.project,
|
||||||
|
unfinishedTasks: unfinished,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Expanded task list grouped by milestone ───────────────────────────────────
|
||||||
|
|
||||||
|
class _ProjectTaskList extends ConsumerWidget {
|
||||||
|
final Project project;
|
||||||
|
final List<Task> unfinishedTasks;
|
||||||
|
const _ProjectTaskList({
|
||||||
|
required this.project,
|
||||||
|
required this.unfinishedTasks,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final milestonesAsync =
|
||||||
|
ref.watch(projectMilestonesProvider(project.id));
|
||||||
|
|
||||||
|
return milestonesAsync.when(
|
||||||
|
loading: () => const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
),
|
||||||
|
error: (e, _) => Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text('Error loading milestones: $e',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.error)),
|
||||||
|
),
|
||||||
|
data: (milestones) {
|
||||||
|
// Only active milestones in order
|
||||||
|
final activeMilestones = milestones
|
||||||
|
.where((m) => m.status == 'active')
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => a.orderIndex.compareTo(b.orderIndex));
|
||||||
|
|
||||||
|
if (unfinishedTasks.isEmpty) {
|
||||||
|
return _EmptyProjectContent(project: project);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build milestone → tasks map
|
||||||
|
final Map<int?, List<Task>> grouped = {};
|
||||||
|
for (final task in unfinishedTasks) {
|
||||||
|
grouped.putIfAbsent(task.milestoneId, () => []).add(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
final widgets = <Widget>[];
|
||||||
|
|
||||||
|
// Milestone groups (in order)
|
||||||
|
for (final ms in activeMilestones) {
|
||||||
|
final tasks = grouped[ms.id];
|
||||||
|
if (tasks == null || tasks.isEmpty) continue;
|
||||||
|
widgets.add(_MilestoneHeader(milestone: ms));
|
||||||
|
for (final task in tasks) {
|
||||||
|
widgets.add(_TaskRow(task: task));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No-milestone group
|
||||||
|
final noMilestoneTasks = grouped[null] ?? [];
|
||||||
|
if (noMilestoneTasks.isNotEmpty) {
|
||||||
|
if (activeMilestones.isNotEmpty) {
|
||||||
|
widgets.add(const _NoMilestoneHeader());
|
||||||
|
}
|
||||||
|
for (final task in noMilestoneTasks) {
|
||||||
|
widgets.add(_TaskRow(task: task));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
widgets.add(_AddTaskRow(project: project));
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: widgets,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptyProjectContent extends StatelessWidget {
|
||||||
|
final Project project;
|
||||||
|
const _EmptyProjectContent({required this.project});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||||
|
child: Text(
|
||||||
|
'No open tasks.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_AddTaskRow(project: project),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Milestone header ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _MilestoneHeader extends StatelessWidget {
|
||||||
|
final Milestone milestone;
|
||||||
|
const _MilestoneHeader({required this.milestone});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pct = milestone.total == 0 ? 0.0 : milestone.pct / 100.0;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(56, 12, 16, 2),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.flag_outlined, size: 14),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
milestone.title,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: colorScheme.secondary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${milestone.completed}/${milestone.total}',
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: colorScheme.outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: pct,
|
||||||
|
minHeight: 3,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
backgroundColor:
|
||||||
|
colorScheme.secondaryContainer.withValues(alpha: 0.4),
|
||||||
|
valueColor:
|
||||||
|
AlwaysStoppedAnimation<Color>(colorScheme.secondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NoMilestoneHeader extends StatelessWidget {
|
||||||
|
const _NoMilestoneHeader();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(56, 12, 16, 2),
|
||||||
|
child: Text(
|
||||||
|
'No milestone',
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Task row ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _TaskRow extends StatelessWidget {
|
||||||
|
final Task task;
|
||||||
|
const _TaskRow({required this.task});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isInProgress = task.status == TaskStatus.inProgress;
|
||||||
|
final dotColor = isInProgress ? colorScheme.primary : colorScheme.outline;
|
||||||
|
final priorityColor = switch (task.priority) {
|
||||||
|
TaskPriority.high => Colors.red,
|
||||||
|
TaskPriority.medium => Colors.orange,
|
||||||
|
TaskPriority.low => Colors.blue,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(56, 0, 16, 0),
|
||||||
|
leading: Icon(
|
||||||
|
isInProgress ? Icons.radio_button_checked : Icons.radio_button_unchecked,
|
||||||
|
size: 18,
|
||||||
|
color: dotColor,
|
||||||
|
),
|
||||||
|
title: Text(task.title, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
|
subtitle: task.dueDate != null
|
||||||
|
? Text(
|
||||||
|
_formatDue(task.dueDate!),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: _isDueOverdue(task.dueDate!)
|
||||||
|
? colorScheme.error
|
||||||
|
: colorScheme.outline,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
trailing: priorityColor != null
|
||||||
|
? Container(
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: priorityColor,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () => context.push(
|
||||||
|
Routes.taskEdit.replaceFirst(':id', '${task.id}'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDue(DateTime due) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final diff = due.difference(DateTime(now.year, now.month, now.day)).inDays;
|
||||||
|
if (diff == 0) return 'Due today';
|
||||||
|
if (diff == 1) return 'Due tomorrow';
|
||||||
|
if (diff < 0) return 'Overdue ${(-diff)} day${(-diff) == 1 ? '' : 's'}';
|
||||||
|
if (diff < 7) return 'Due in $diff days';
|
||||||
|
return 'Due ${due.month}/${due.day}';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isDueOverdue(DateTime due) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
return due.isBefore(DateTime(now.year, now.month, now.day));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Add task row ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _AddTaskRow extends StatelessWidget {
|
||||||
|
final Project project;
|
||||||
|
const _AddTaskRow({required this.project});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(48, 4, 16, 8),
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: () => context.push(Routes.taskNew),
|
||||||
|
icon: const Icon(Icons.add, size: 16),
|
||||||
|
label: const Text('New task'),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.outline,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
textStyle: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Status chip ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class _StatusChip extends StatelessWidget {
|
class _StatusChip extends StatelessWidget {
|
||||||
final String status;
|
final String status;
|
||||||
const _StatusChip({required this.status});
|
const _StatusChip({required this.status});
|
||||||
@@ -217,6 +530,8 @@ class _StatusChip extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Create project dialog ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
class _CreateProjectDialog extends StatefulWidget {
|
class _CreateProjectDialog extends StatefulWidget {
|
||||||
final Future<void> Function(String title, String? description, String? goal)
|
final Future<void> Function(String title, String? description, String? goal)
|
||||||
onCreate;
|
onCreate;
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ name: fabled_app
|
|||||||
description: "FabledAssistant mobile client for Android."
|
description: "FabledAssistant mobile client for Android."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 26.03.01+1
|
version: 26.03.02+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0
|
sdk: ^3.11.0
|
||||||
|
|||||||
Reference in New Issue
Block a user