Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c3055d536 | |||
| 868cb0e49e | |||
| fceae5529d | |||
| f30aa8d273 | |||
| abf91874c3 | |||
| 54c2588bd6 | |||
| ccaec61de2 | |||
| 467fa6a553 | |||
| 140f6cf63a | |||
| 4c2b2a0d1a |
+2
-4
@@ -7,8 +7,6 @@ gradle-wrapper.jar
|
|||||||
GeneratedPluginRegistrant.java
|
GeneratedPluginRegistrant.java
|
||||||
.cxx/
|
.cxx/
|
||||||
|
|
||||||
# Remember to never publicly share your keystore.
|
# Signing secrets — never commit these
|
||||||
# See https://flutter.dev/to/reference-keystore
|
|
||||||
key.properties
|
key.properties
|
||||||
**/*.keystore
|
fabled-release-key.jks
|
||||||
**/*.jks
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
@@ -5,6 +8,12 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.fabledapp.fabled_app"
|
namespace = "com.fabledapp.fabled_app"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -20,21 +29,37 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
|
||||||
applicationId = "com.fabledapp.fabled_app"
|
applicationId = "com.fabledapp.fabled_app"
|
||||||
// You can update the following values to match your application needs.
|
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
keyAlias = keystoreProperties["keyAlias"] as String
|
||||||
|
keyPassword = keystoreProperties["keyPassword"] as String
|
||||||
|
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||||
|
storePassword = keystoreProperties["storePassword"] as String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
signingConfigs.getByName("release")
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
} else {
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applicationVariants.all {
|
||||||
|
val variant = this
|
||||||
|
outputs.all {
|
||||||
|
val output = this as? com.android.build.gradle.internal.api.BaseVariantOutputImpl
|
||||||
|
output?.outputFileName = "Fabled-${variant.versionName}.${variant.versionCode}.apk"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="fabled_app"
|
android:label="Fabled"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true">
|
||||||
|
|||||||
+23
-2
@@ -7,7 +7,9 @@ import 'core/exceptions.dart';
|
|||||||
import 'providers/api_client_provider.dart';
|
import 'providers/api_client_provider.dart';
|
||||||
import 'providers/auth_provider.dart';
|
import 'providers/auth_provider.dart';
|
||||||
import 'providers/capture_queue_provider.dart';
|
import 'providers/capture_queue_provider.dart';
|
||||||
|
import 'providers/milestones_provider.dart';
|
||||||
import 'providers/notes_provider.dart';
|
import 'providers/notes_provider.dart';
|
||||||
|
import 'providers/projects_provider.dart';
|
||||||
import 'providers/settings_provider.dart';
|
import 'providers/settings_provider.dart';
|
||||||
import 'providers/update_provider.dart';
|
import 'providers/update_provider.dart';
|
||||||
import 'providers/tasks_provider.dart';
|
import 'providers/tasks_provider.dart';
|
||||||
@@ -17,6 +19,7 @@ import 'screens/chat/conversations_list_screen.dart';
|
|||||||
import 'screens/notes/note_detail_screen.dart';
|
import 'screens/notes/note_detail_screen.dart';
|
||||||
import 'screens/notes/note_edit_screen.dart';
|
import 'screens/notes/note_edit_screen.dart';
|
||||||
import 'screens/notes/notes_list_screen.dart';
|
import 'screens/notes/notes_list_screen.dart';
|
||||||
|
import 'screens/projects/project_list_screen.dart';
|
||||||
import 'screens/settings/settings_screen.dart';
|
import 'screens/settings/settings_screen.dart';
|
||||||
import 'screens/setup/setup_screen.dart';
|
import 'screens/setup/setup_screen.dart';
|
||||||
import 'screens/splash/splash_screen.dart';
|
import 'screens/splash/splash_screen.dart';
|
||||||
@@ -93,7 +96,11 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.taskNew,
|
path: Routes.taskNew,
|
||||||
builder: (_, _) => const TaskEditScreen(),
|
builder: (_, state) => TaskEditScreen(
|
||||||
|
initialProjectId: state.uri.queryParameters['projectId'] != null
|
||||||
|
? int.tryParse(state.uri.queryParameters['projectId']!)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.taskEdit,
|
path: Routes.taskEdit,
|
||||||
@@ -118,6 +125,10 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
path: Routes.tasks,
|
path: Routes.tasks,
|
||||||
builder: (_, _) => const TasksListScreen(),
|
builder: (_, _) => const TasksListScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: Routes.projects,
|
||||||
|
builder: (_, _) => const ProjectListScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.conversations,
|
path: Routes.conversations,
|
||||||
builder: (_, _) => const ConversationsListScreen(),
|
builder: (_, _) => const ConversationsListScreen(),
|
||||||
@@ -137,7 +148,7 @@ class _Shell extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ShellState extends ConsumerState<_Shell> {
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -253,6 +264,11 @@ class _ShellState extends ConsumerState<_Shell> {
|
|||||||
selectedIcon: Icon(Icons.check_box),
|
selectedIcon: Icon(Icons.check_box),
|
||||||
label: Text('Tasks'),
|
label: Text('Tasks'),
|
||||||
),
|
),
|
||||||
|
NavigationRailDestination(
|
||||||
|
icon: Icon(Icons.folder_outlined),
|
||||||
|
selectedIcon: Icon(Icons.folder),
|
||||||
|
label: Text('Projects'),
|
||||||
|
),
|
||||||
NavigationRailDestination(
|
NavigationRailDestination(
|
||||||
icon: Icon(Icons.chat_bubble_outline),
|
icon: Icon(Icons.chat_bubble_outline),
|
||||||
selectedIcon: Icon(Icons.chat_bubble),
|
selectedIcon: Icon(Icons.chat_bubble),
|
||||||
@@ -284,6 +300,7 @@ class _ShellState extends ConsumerState<_Shell> {
|
|||||||
destinations: const [
|
destinations: const [
|
||||||
NavigationDestination(icon: Icon(Icons.note), label: 'Notes'),
|
NavigationDestination(icon: Icon(Icons.note), label: 'Notes'),
|
||||||
NavigationDestination(icon: Icon(Icons.check_box), label: 'Tasks'),
|
NavigationDestination(icon: Icon(Icons.check_box), label: 'Tasks'),
|
||||||
|
NavigationDestination(icon: Icon(Icons.folder), label: 'Projects'),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.chat_bubble), label: 'Chat'),
|
icon: Icon(Icons.chat_bubble), label: 'Chat'),
|
||||||
],
|
],
|
||||||
@@ -336,6 +353,8 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
|||||||
case 'task':
|
case 'task':
|
||||||
case 'todo':
|
case 'todo':
|
||||||
ref.invalidate(tasksProvider);
|
ref.invalidate(tasksProvider);
|
||||||
|
ref.invalidate(projectsProvider);
|
||||||
|
ref.invalidate(projectMilestonesProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
final msg = result.message.isNotEmpty
|
final msg = result.message.isNotEmpty
|
||||||
@@ -387,6 +406,8 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
|||||||
case 'task':
|
case 'task':
|
||||||
case 'todo':
|
case 'todo':
|
||||||
ref.invalidate(tasksProvider);
|
ref.invalidate(tasksProvider);
|
||||||
|
ref.invalidate(projectsProvider);
|
||||||
|
ref.invalidate(projectMilestonesProvider);
|
||||||
}
|
}
|
||||||
} on NetworkException {
|
} on NetworkException {
|
||||||
break; // Still offline — stop draining.
|
break; // Still offline — stop draining.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ abstract class Routes {
|
|||||||
static const tasks = '/tasks';
|
static const tasks = '/tasks';
|
||||||
static const taskNew = '/tasks/new';
|
static const taskNew = '/tasks/new';
|
||||||
static const taskEdit = '/tasks/:id/edit';
|
static const taskEdit = '/tasks/:id/edit';
|
||||||
|
static const projects = '/projects';
|
||||||
static const conversations = '/chat';
|
static const conversations = '/chat';
|
||||||
static const chat = '/chat/:id';
|
static const chat = '/chat/:id';
|
||||||
static const quickCapture = '/quick-capture';
|
static const quickCapture = '/quick-capture';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
try {
|
||||||
final response = await _dio.post('/api/notes', data: {
|
final response = await _dio.post('/api/notes', data: {
|
||||||
'title': title,
|
'title': title,
|
||||||
'body': body,
|
'body': body,
|
||||||
|
'tags': tags,
|
||||||
|
if (projectId != null) 'project_id': projectId,
|
||||||
});
|
});
|
||||||
return Note.fromJson(response.data as Map<String, dynamic>);
|
return Note.fromJson(response.data as Map<String, dynamic>);
|
||||||
} on DioException catch (e) {
|
} 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 {
|
try {
|
||||||
final response = await _dio.put('/api/notes/$id', data: {
|
final response = await _dio.put('/api/notes/$id', data: {
|
||||||
'title': title,
|
'title': title,
|
||||||
'body': body,
|
'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>);
|
return Note.fromJson(response.data as Map<String, dynamic>);
|
||||||
} on DioException catch (e) {
|
} 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,8 @@ class TasksApi {
|
|||||||
required TaskStatus status,
|
required TaskStatus status,
|
||||||
required TaskPriority priority,
|
required TaskPriority priority,
|
||||||
DateTime? dueDate,
|
DateTime? dueDate,
|
||||||
|
int? projectId,
|
||||||
|
int? parentId,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final response = await _dio.post('/api/tasks', data: {
|
final response = await _dio.post('/api/tasks', data: {
|
||||||
@@ -41,6 +43,8 @@ class TasksApi {
|
|||||||
'status': status.value,
|
'status': status.value,
|
||||||
'priority': priority.value,
|
'priority': priority.value,
|
||||||
'due_date': dueDate?.toIso8601String(),
|
'due_date': dueDate?.toIso8601String(),
|
||||||
|
if (projectId != null) 'project_id': projectId,
|
||||||
|
if (parentId != null) 'parent_id': parentId,
|
||||||
});
|
});
|
||||||
return Task.fromJson(response.data as Map<String, dynamic>);
|
return Task.fromJson(response.data as Map<String, dynamic>);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
@@ -48,6 +52,20 @@ class TasksApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<Task>> getSubTasks(int parentId) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get(
|
||||||
|
'/api/notes',
|
||||||
|
queryParameters: {'parent_id': parentId, 'is_task': 'true', 'limit': 100},
|
||||||
|
);
|
||||||
|
final data = response.data as Map<String, dynamic>;
|
||||||
|
final list = data['notes'] as List<dynamic>;
|
||||||
|
return list.map((e) => Task.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<Task> update(int id, Map<String, dynamic> fields) async {
|
Future<Task> update(int id, Map<String, dynamic> fields) async {
|
||||||
try {
|
try {
|
||||||
final response = await _dio.put('/api/tasks/$id', data: fields);
|
final response = await _dio.put('/api/tasks/$id', data: fields);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ class Note {
|
|||||||
final int id;
|
final int id;
|
||||||
final String title;
|
final String title;
|
||||||
final String body;
|
final String body;
|
||||||
|
final List<String> tags;
|
||||||
|
final int? projectId;
|
||||||
|
final int? milestoneId;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final DateTime updatedAt;
|
final DateTime updatedAt;
|
||||||
|
|
||||||
@@ -9,6 +12,9 @@ class Note {
|
|||||||
required this.id,
|
required this.id,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.body,
|
required this.body,
|
||||||
|
required this.tags,
|
||||||
|
this.projectId,
|
||||||
|
this.milestoneId,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
});
|
});
|
||||||
@@ -17,6 +23,12 @@ class Note {
|
|||||||
id: json['id'] as int,
|
id: json['id'] as int,
|
||||||
title: json['title'] as String? ?? '',
|
title: json['title'] as String? ?? '',
|
||||||
body: json['body'] 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),
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||||
);
|
);
|
||||||
@@ -24,13 +36,32 @@ class Note {
|
|||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'title': title,
|
'title': title,
|
||||||
'body': body,
|
'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,
|
id: id,
|
||||||
title: title ?? this.title,
|
title: title ?? this.title,
|
||||||
body: body ?? this.body,
|
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,
|
createdAt: createdAt,
|
||||||
updatedAt: updatedAt,
|
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 TaskStatus status;
|
||||||
final TaskPriority priority;
|
final TaskPriority priority;
|
||||||
final DateTime? dueDate;
|
final DateTime? dueDate;
|
||||||
|
final int? projectId;
|
||||||
|
final int? milestoneId;
|
||||||
|
final int? parentId;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final DateTime updatedAt;
|
final DateTime updatedAt;
|
||||||
|
|
||||||
@@ -62,6 +65,9 @@ class Task {
|
|||||||
required this.status,
|
required this.status,
|
||||||
required this.priority,
|
required this.priority,
|
||||||
this.dueDate,
|
this.dueDate,
|
||||||
|
this.projectId,
|
||||||
|
this.milestoneId,
|
||||||
|
this.parentId,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
});
|
});
|
||||||
@@ -75,6 +81,9 @@ class Task {
|
|||||||
dueDate: json['due_date'] != null
|
dueDate: json['due_date'] != null
|
||||||
? DateTime.parse(json['due_date'] as String)
|
? DateTime.parse(json['due_date'] as String)
|
||||||
: null,
|
: 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),
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||||
);
|
);
|
||||||
@@ -85,6 +94,9 @@ class Task {
|
|||||||
'status': status.value,
|
'status': status.value,
|
||||||
'priority': priority.value,
|
'priority': priority.value,
|
||||||
'due_date': dueDate?.toIso8601String(),
|
'due_date': dueDate?.toIso8601String(),
|
||||||
|
'project_id': projectId,
|
||||||
|
'milestone_id': milestoneId,
|
||||||
|
'parent_id': parentId,
|
||||||
};
|
};
|
||||||
|
|
||||||
Task copyWith({
|
Task copyWith({
|
||||||
@@ -93,6 +105,9 @@ class Task {
|
|||||||
TaskStatus? status,
|
TaskStatus? status,
|
||||||
TaskPriority? priority,
|
TaskPriority? priority,
|
||||||
DateTime? dueDate,
|
DateTime? dueDate,
|
||||||
|
Object? projectId = _undefined,
|
||||||
|
Object? milestoneId = _undefined,
|
||||||
|
Object? parentId = _undefined,
|
||||||
}) =>
|
}) =>
|
||||||
Task(
|
Task(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -101,7 +116,18 @@ class Task {
|
|||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
priority: priority ?? this.priority,
|
priority: priority ?? this.priority,
|
||||||
dueDate: dueDate ?? this.dueDate,
|
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,
|
createdAt: createdAt,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static const _undefined = Object();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -7,9 +7,25 @@ class NotesRepository {
|
|||||||
|
|
||||||
Future<List<Note>> getAll() => _api.getAll();
|
Future<List<Note>> getAll() => _api.getAll();
|
||||||
Future<Note> getOne(int id) => _api.getOne(id);
|
Future<Note> getOne(int id) => _api.getOne(id);
|
||||||
Future<Note> create(String title, String body) =>
|
|
||||||
_api.create(title, body);
|
Future<Note> create(
|
||||||
Future<Note> update(int id, String title, String body) =>
|
String title,
|
||||||
_api.update(id, title, body);
|
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);
|
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,8 @@ class TasksRepository {
|
|||||||
required TaskStatus status,
|
required TaskStatus status,
|
||||||
required TaskPriority priority,
|
required TaskPriority priority,
|
||||||
DateTime? dueDate,
|
DateTime? dueDate,
|
||||||
|
int? projectId,
|
||||||
|
int? parentId,
|
||||||
}) =>
|
}) =>
|
||||||
_api.create(
|
_api.create(
|
||||||
title: title,
|
title: title,
|
||||||
@@ -21,8 +23,12 @@ class TasksRepository {
|
|||||||
status: status,
|
status: status,
|
||||||
priority: priority,
|
priority: priority,
|
||||||
dueDate: dueDate,
|
dueDate: dueDate,
|
||||||
|
projectId: projectId,
|
||||||
|
parentId: parentId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Future<List<Task>> getSubTasks(int parentId) => _api.getSubTasks(parentId);
|
||||||
|
|
||||||
Future<Task> update(int id, Map<String, dynamic> fields) =>
|
Future<Task> update(int id, Map<String, dynamic> fields) =>
|
||||||
_api.update(id, fields);
|
_api.update(id, fields);
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ 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/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/tasks_repository.dart';
|
import '../data/repositories/tasks_repository.dart';
|
||||||
import 'settings_provider.dart';
|
import 'settings_provider.dart';
|
||||||
|
|
||||||
@@ -45,6 +49,10 @@ final quickCaptureApiProvider = Provider<QuickCaptureApi>((ref) {
|
|||||||
return QuickCaptureApi(ref.watch(dioProvider));
|
return QuickCaptureApi(ref.watch(dioProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final projectsApiProvider = Provider<ProjectsApi>((ref) {
|
||||||
|
return ProjectsApi(ref.watch(dioProvider));
|
||||||
|
});
|
||||||
|
|
||||||
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||||
return AuthRepository(ref.watch(authApiProvider));
|
return AuthRepository(ref.watch(authApiProvider));
|
||||||
});
|
});
|
||||||
@@ -60,3 +68,15 @@ final tasksRepositoryProvider = Provider<TasksRepository>((ref) {
|
|||||||
final chatRepositoryProvider = Provider<ChatRepository>((ref) {
|
final chatRepositoryProvider = Provider<ChatRepository>((ref) {
|
||||||
return ChatRepository(ref.watch(chatApiProvider));
|
return ChatRepository(ref.watch(chatApiProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final projectsRepositoryProvider = Provider<ProjectsRepository>((ref) {
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -12,16 +12,35 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
|
|||||||
return ref.watch(notesRepositoryProvider).getAll();
|
return ref.watch(notesRepositoryProvider).getAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Note> create(String title, String body) async {
|
Future<Note> create(
|
||||||
final note =
|
String title,
|
||||||
await ref.read(notesRepositoryProvider).create(title, body);
|
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]);
|
state = AsyncData([...state.valueOrNull ?? [], note]);
|
||||||
return note;
|
return note;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Note> updateNote(int id, String title, String body) async {
|
Future<Note> updateNote(
|
||||||
final updated =
|
int id,
|
||||||
await ref.read(notesRepositoryProvider).update(id, title, body);
|
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([
|
state = AsyncData([
|
||||||
for (final n in state.valueOrNull ?? [])
|
for (final n in state.valueOrNull ?? [])
|
||||||
if (n.id == id) updated else n,
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,8 @@ final forgejoRepoUrlProvider =
|
|||||||
class ForgejoRepoUrlNotifier extends StateNotifier<String?> {
|
class ForgejoRepoUrlNotifier extends StateNotifier<String?> {
|
||||||
final SharedPreferences _prefs;
|
final SharedPreferences _prefs;
|
||||||
ForgejoRepoUrlNotifier(this._prefs)
|
ForgejoRepoUrlNotifier(this._prefs)
|
||||||
: super(_prefs.getString(_kForgejoRepoUrl));
|
: super(_prefs.getString(_kForgejoRepoUrl) ??
|
||||||
|
'https://git.fabledsword.com/bvandeusen/FabledApp');
|
||||||
|
|
||||||
Future<void> setUrl(String url) async {
|
Future<void> setUrl(String url) async {
|
||||||
final clean = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
final clean = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
|
|||||||
required TaskStatus status,
|
required TaskStatus status,
|
||||||
required TaskPriority priority,
|
required TaskPriority priority,
|
||||||
DateTime? dueDate,
|
DateTime? dueDate,
|
||||||
|
int? projectId,
|
||||||
}) async {
|
}) async {
|
||||||
final task = await ref.read(tasksRepositoryProvider).create(
|
final task = await ref.read(tasksRepositoryProvider).create(
|
||||||
title: title,
|
title: title,
|
||||||
@@ -25,6 +26,7 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
|
|||||||
status: status,
|
status: status,
|
||||||
priority: priority,
|
priority: priority,
|
||||||
dueDate: dueDate,
|
dueDate: dueDate,
|
||||||
|
projectId: projectId,
|
||||||
);
|
);
|
||||||
state = AsyncData([...state.valueOrNull ?? [], task]);
|
state = AsyncData([...state.valueOrNull ?? [], task]);
|
||||||
return task;
|
return task;
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
|||||||
state = state.copyWith(status: UpdateStatus.checking);
|
state = state.copyWith(status: UpdateStatus.checking);
|
||||||
try {
|
try {
|
||||||
final packageInfo = await PackageInfo.fromPlatform();
|
final packageInfo = await PackageInfo.fromPlatform();
|
||||||
final currentVersion = packageInfo.version;
|
// Combine versionName + buildNumber to match the YY.MM.DD.N tag format.
|
||||||
|
final currentVersion =
|
||||||
|
'${packageInfo.version}.${packageInfo.buildNumber}';
|
||||||
|
|
||||||
// Parse repo URL → Forgejo API endpoint
|
// Parse repo URL → Forgejo API endpoint
|
||||||
final uri = Uri.parse(repoUrl);
|
final uri = Uri.parse(repoUrl);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'dart:math' show min;
|
import 'dart:math' show min;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../core/exceptions.dart';
|
import '../../core/exceptions.dart';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ import '../../core/exceptions.dart';
|
|||||||
import '../../core/wikilink_syntax.dart';
|
import '../../core/wikilink_syntax.dart';
|
||||||
import '../../providers/api_client_provider.dart';
|
import '../../providers/api_client_provider.dart';
|
||||||
import '../../providers/notes_provider.dart';
|
import '../../providers/notes_provider.dart';
|
||||||
|
import '../../widgets/project_selector.dart';
|
||||||
|
|
||||||
class NoteEditScreen extends ConsumerStatefulWidget {
|
class NoteEditScreen extends ConsumerStatefulWidget {
|
||||||
final int? noteId;
|
final int? noteId;
|
||||||
@@ -19,10 +20,12 @@ class NoteEditScreen extends ConsumerStatefulWidget {
|
|||||||
class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||||
final _titleController = TextEditingController();
|
final _titleController = TextEditingController();
|
||||||
final _contentController = TextEditingController();
|
final _contentController = TextEditingController();
|
||||||
|
final _tagController = TextEditingController();
|
||||||
|
List<String> _tags = [];
|
||||||
|
int? _projectId;
|
||||||
bool _preview = false;
|
bool _preview = false;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
|
|
||||||
// Future is created once in initState so FutureBuilder never restarts it.
|
|
||||||
late final Future<void> _initFuture;
|
late final Future<void> _initFuture;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -36,6 +39,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_titleController.dispose();
|
_titleController.dispose();
|
||||||
_contentController.dispose();
|
_contentController.dispose();
|
||||||
|
_tagController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +48,24 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
|||||||
await ref.read(notesRepositoryProvider).getOne(widget.noteId!);
|
await ref.read(notesRepositoryProvider).getOne(widget.noteId!);
|
||||||
_titleController.text = note.title;
|
_titleController.text = note.title;
|
||||||
_contentController.text = note.body;
|
_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 {
|
Future<void> _delete() async {
|
||||||
@@ -81,12 +103,22 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
|||||||
setState(() => _saving = true);
|
setState(() => _saving = true);
|
||||||
try {
|
try {
|
||||||
if (widget.noteId == null) {
|
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();
|
if (mounted) context.pop();
|
||||||
} else {
|
} else {
|
||||||
await ref
|
await ref.read(notesProvider.notifier).updateNote(
|
||||||
.read(notesProvider.notifier)
|
widget.noteId!,
|
||||||
.updateNote(widget.noteId!, title, body);
|
title,
|
||||||
|
body,
|
||||||
|
tags: _tags,
|
||||||
|
projectId: _projectId,
|
||||||
|
clearProject: _projectId == null,
|
||||||
|
);
|
||||||
if (mounted) context.pop();
|
if (mounted) context.pop();
|
||||||
}
|
}
|
||||||
} on AppException catch (e) {
|
} on AppException catch (e) {
|
||||||
@@ -147,7 +179,23 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
|||||||
textInputAction: TextInputAction.next,
|
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(
|
Expanded(
|
||||||
child: _preview
|
child: _preview
|
||||||
? Markdown(
|
? 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,631 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/constants.dart';
|
||||||
|
import '../../core/exceptions.dart';
|
||||||
|
import '../../data/models/milestone.dart';
|
||||||
|
import '../../data/models/project.dart';
|
||||||
|
import '../../data/models/task.dart';
|
||||||
|
import '../../providers/milestones_provider.dart';
|
||||||
|
import '../../providers/projects_provider.dart';
|
||||||
|
import '../../providers/tasks_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.only(bottom: 88),
|
||||||
|
children: [
|
||||||
|
if (active.isNotEmpty) ...[
|
||||||
|
_SectionHeader(title: 'Active (${active.length})'),
|
||||||
|
...active.map((p) => _ProjectExpansionTile(project: p)),
|
||||||
|
],
|
||||||
|
if (other.isNotEmpty) ...[
|
||||||
|
_SectionHeader(title: 'Other'),
|
||||||
|
...other.map((p) => _ProjectExpansionTile(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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Section header ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Project expansion tile ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _ProjectExpansionTile extends ConsumerStatefulWidget {
|
||||||
|
final Project project;
|
||||||
|
const _ProjectExpansionTile({required this.project});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_ProjectExpansionTile> createState() =>
|
||||||
|
_ProjectExpansionTileState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProjectExpansionTileState
|
||||||
|
extends ConsumerState<_ProjectExpansionTile> {
|
||||||
|
bool _expanded = false;
|
||||||
|
|
||||||
|
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>(
|
||||||
|
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(
|
||||||
|
widget.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(
|
||||||
|
widget.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: const 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(widget.project.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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}?projectId=${project.id}'),
|
||||||
|
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 {
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Create project dialog ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,19 +36,33 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
if (url.endsWith('/')) url = url.substring(0, url.length - 1);
|
if (url.endsWith('/')) url = url.substring(0, url.length - 1);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final dio = Dio(BaseOptions(connectTimeout: const Duration(seconds: 5)));
|
final dio = Dio(BaseOptions(
|
||||||
|
connectTimeout: const Duration(seconds: 10),
|
||||||
|
// Accept any HTTP status — only network-level failures throw.
|
||||||
|
// A 401 or 200 both mean the server is reachable.
|
||||||
|
validateStatus: (_) => true,
|
||||||
|
));
|
||||||
await dio.get('$url/api/auth/status');
|
await dio.get('$url/api/auth/status');
|
||||||
// 401 is fine — server is reachable
|
} on DioException catch (e) {
|
||||||
} on DioException catch (_) {
|
final msg = switch (e.type) {
|
||||||
|
DioExceptionType.badCertificate =>
|
||||||
|
'SSL certificate error. If using a self-signed cert, it must be trusted on this device.',
|
||||||
|
DioExceptionType.connectionTimeout ||
|
||||||
|
DioExceptionType.receiveTimeout =>
|
||||||
|
'Connection timed out. The server may be down or unreachable.',
|
||||||
|
DioExceptionType.connectionError =>
|
||||||
|
'Cannot connect: ${e.message ?? "network error"}',
|
||||||
|
_ => 'Error: ${e.message ?? e.type.name}',
|
||||||
|
};
|
||||||
setState(() {
|
setState(() {
|
||||||
_testing = false;
|
_testing = false;
|
||||||
_error = 'Could not reach server. Check the URL and try again.';
|
_error = msg;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_testing = false;
|
_testing = false;
|
||||||
_error = 'Could not reach server. Check the URL and try again.';
|
_error = 'Unexpected error: $e';
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ 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 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/constants.dart';
|
||||||
import '../../core/exceptions.dart';
|
import '../../core/exceptions.dart';
|
||||||
|
import '../../data/api/tasks_api.dart';
|
||||||
import '../../data/models/task.dart';
|
import '../../data/models/task.dart';
|
||||||
import '../../providers/api_client_provider.dart';
|
import '../../providers/api_client_provider.dart';
|
||||||
import '../../providers/tasks_provider.dart';
|
import '../../providers/tasks_provider.dart';
|
||||||
|
import '../../widgets/project_selector.dart';
|
||||||
|
|
||||||
class TaskEditScreen extends ConsumerStatefulWidget {
|
class TaskEditScreen extends ConsumerStatefulWidget {
|
||||||
final int? taskId;
|
final int? taskId;
|
||||||
const TaskEditScreen({super.key, this.taskId});
|
final int? initialProjectId;
|
||||||
|
const TaskEditScreen({super.key, this.taskId, this.initialProjectId});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<TaskEditScreen> createState() => _TaskEditScreenState();
|
ConsumerState<TaskEditScreen> createState() => _TaskEditScreenState();
|
||||||
@@ -22,14 +26,16 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
|||||||
TaskStatus _status = TaskStatus.todo;
|
TaskStatus _status = TaskStatus.todo;
|
||||||
TaskPriority _priority = TaskPriority.medium;
|
TaskPriority _priority = TaskPriority.medium;
|
||||||
DateTime? _dueDate;
|
DateTime? _dueDate;
|
||||||
|
int? _projectId;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
|
List<Task> _subTasks = [];
|
||||||
|
|
||||||
// Future is created once in initState so FutureBuilder never restarts it.
|
|
||||||
late final Future<void> _initFuture;
|
late final Future<void> _initFuture;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_projectId = widget.initialProjectId;
|
||||||
_initFuture =
|
_initFuture =
|
||||||
widget.taskId != null ? _loadExisting() : Future.value();
|
widget.taskId != null ? _loadExisting() : Future.value();
|
||||||
}
|
}
|
||||||
@@ -42,13 +48,18 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadExisting() async {
|
Future<void> _loadExisting() async {
|
||||||
final task =
|
final repo = ref.read(tasksRepositoryProvider);
|
||||||
await ref.read(tasksRepositoryProvider).getOne(widget.taskId!);
|
final task = await repo.getOne(widget.taskId!);
|
||||||
_titleController.text = task.title;
|
_titleController.text = task.title;
|
||||||
_descController.text = task.description ?? '';
|
_descController.text = task.description ?? '';
|
||||||
_status = task.status;
|
_status = task.status;
|
||||||
_priority = task.priority;
|
_priority = task.priority;
|
||||||
_dueDate = task.dueDate;
|
_dueDate = task.dueDate;
|
||||||
|
_projectId = task.projectId;
|
||||||
|
// Fetch sub-tasks
|
||||||
|
final api = ref.read(tasksApiProvider);
|
||||||
|
final subs = await api.getSubTasks(widget.taskId!);
|
||||||
|
setState(() => _subTasks = subs);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
@@ -64,16 +75,18 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
|||||||
status: _status,
|
status: _status,
|
||||||
priority: _priority,
|
priority: _priority,
|
||||||
dueDate: _dueDate,
|
dueDate: _dueDate,
|
||||||
|
projectId: _projectId,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await ref.read(tasksProvider.notifier).updateTask(widget.taskId!, {
|
await ref.read(tasksProvider.notifier).updateTask(widget.taskId!, {
|
||||||
'title': _titleController.text.trim(),
|
'title': _titleController.text.trim(),
|
||||||
'description': _descController.text.trim().isEmpty
|
'body': _descController.text.trim().isEmpty
|
||||||
? null
|
? null
|
||||||
: _descController.text.trim(),
|
: _descController.text.trim(),
|
||||||
'status': _status.value,
|
'status': _status.value,
|
||||||
'priority': _priority.value,
|
'priority': _priority.value,
|
||||||
'due_date': _dueDate?.toIso8601String(),
|
'due_date': _dueDate?.toIso8601String(),
|
||||||
|
'project_id': _projectId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (mounted) context.pop();
|
if (mounted) context.pop();
|
||||||
@@ -119,6 +132,54 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
|||||||
if (date != null) setState(() => _dueDate = date);
|
if (date != null) setState(() => _dueDate = date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _addSubTask() async {
|
||||||
|
final titleCtrl = TextEditingController();
|
||||||
|
final result = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Add sub-task'),
|
||||||
|
content: TextField(
|
||||||
|
controller: titleCtrl,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Title',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (v) => Navigator.pop(ctx, v.trim()),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.pop(ctx, titleCtrl.text.trim()),
|
||||||
|
child: const Text('Add')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
titleCtrl.dispose();
|
||||||
|
if (result == null || result.isEmpty || !mounted) return;
|
||||||
|
try {
|
||||||
|
final api = ref.read(tasksApiProvider);
|
||||||
|
final sub = await api.create(
|
||||||
|
title: result,
|
||||||
|
status: TaskStatus.todo,
|
||||||
|
priority: TaskPriority.none,
|
||||||
|
projectId: _projectId,
|
||||||
|
parentId: widget.taskId,
|
||||||
|
);
|
||||||
|
ref.invalidate(tasksProvider);
|
||||||
|
setState(() => _subTasks = [..._subTasks, sub]);
|
||||||
|
} on AppException catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text(e.message)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return FutureBuilder(
|
return FutureBuilder(
|
||||||
@@ -155,70 +216,88 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
|||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _titleController,
|
controller: _titleController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Title',
|
labelText: 'Title',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
validator: (v) =>
|
validator: (v) =>
|
||||||
(v == null || v.trim().isEmpty) ? 'Required' : null,
|
(v == null || v.trim().isEmpty)
|
||||||
|
? 'Required'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _descController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Description (optional)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<TaskStatus>(
|
||||||
|
value: _status,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Status',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: TaskStatus.values
|
||||||
|
.map((s) => DropdownMenuItem(
|
||||||
|
value: s, child: Text(s.label)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) =>
|
||||||
|
setState(() => _status = v!),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<TaskPriority>(
|
||||||
|
value: _priority,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Priority',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: TaskPriority.values
|
||||||
|
.map((p) => DropdownMenuItem(
|
||||||
|
value: p, child: Text(p.label)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) =>
|
||||||
|
setState(() => _priority = v!),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(_dueDate == null
|
||||||
|
? 'No due date'
|
||||||
|
: 'Due: ${_dueDate!.toLocal().toString().substring(0, 10)}'),
|
||||||
|
leading: const Icon(Icons.calendar_today),
|
||||||
|
trailing: _dueDate != null
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () =>
|
||||||
|
setState(() => _dueDate = null),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: _pickDate,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ProjectSelector(
|
||||||
|
value: _projectId,
|
||||||
|
onChanged: (id) =>
|
||||||
|
setState(() => _projectId = id),
|
||||||
|
),
|
||||||
|
// Sub-tasks (only when editing an existing task)
|
||||||
|
if (widget.taskId != null) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_SubTasksSection(
|
||||||
|
subTasks: _subTasks,
|
||||||
|
onAdd: _addSubTask,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
TextFormField(
|
|
||||||
controller: _descController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Description (optional)',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
maxLines: 3,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
DropdownButtonFormField<TaskStatus>(
|
|
||||||
initialValue: _status,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Status',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
items: TaskStatus.values
|
|
||||||
.map((s) => DropdownMenuItem(
|
|
||||||
value: s, child: Text(s.label)))
|
|
||||||
.toList(),
|
|
||||||
onChanged: (v) => setState(() => _status = v!),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
DropdownButtonFormField<TaskPriority>(
|
|
||||||
initialValue: _priority,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Priority',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
items: TaskPriority.values
|
|
||||||
.map((p) => DropdownMenuItem(
|
|
||||||
value: p, child: Text(p.label)))
|
|
||||||
.toList(),
|
|
||||||
onChanged: (v) => setState(() => _priority = v!),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: Text(_dueDate == null
|
|
||||||
? 'No due date'
|
|
||||||
: 'Due: ${_dueDate!.toLocal().toString().substring(0, 10)}'),
|
|
||||||
leading: const Icon(Icons.calendar_today),
|
|
||||||
trailing: _dueDate != null
|
|
||||||
? IconButton(
|
|
||||||
icon: const Icon(Icons.clear),
|
|
||||||
onPressed: () =>
|
|
||||||
setState(() => _dueDate = null),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
onTap: _pickDate,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -226,3 +305,91 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Sub-tasks section ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _SubTasksSection extends ConsumerWidget {
|
||||||
|
final List<Task> subTasks;
|
||||||
|
final VoidCallback onAdd;
|
||||||
|
const _SubTasksSection({required this.subTasks, required this.onAdd});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Sub-tasks',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: onAdd,
|
||||||
|
icon: const Icon(Icons.add, size: 16),
|
||||||
|
label: const Text('Add'),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: colorScheme.primary,
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
textStyle: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (subTasks.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Text(
|
||||||
|
'No sub-tasks yet.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
...subTasks.map((sub) => _SubTaskTile(task: sub, ref: ref)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SubTaskTile extends StatelessWidget {
|
||||||
|
final Task task;
|
||||||
|
final WidgetRef ref;
|
||||||
|
const _SubTaskTile({required this.task, required this.ref});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDone = task.status == TaskStatus.done;
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: Checkbox(
|
||||||
|
value: isDone,
|
||||||
|
onChanged: (_) async {
|
||||||
|
final newStatus =
|
||||||
|
isDone ? TaskStatus.todo : TaskStatus.done;
|
||||||
|
await ref.read(tasksProvider.notifier).updateTask(
|
||||||
|
task.id, {'status': newStatus.value});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
task.title,
|
||||||
|
style: TextStyle(
|
||||||
|
decoration: isDone ? TextDecoration.lineThrough : null,
|
||||||
|
color: isDone
|
||||||
|
? Theme.of(context).colorScheme.outline
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () => context.push(
|
||||||
|
Routes.taskEdit.replaceFirst(':id', '${task.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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -238,14 +238,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
flutter_markdown:
|
flutter_markdown_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_markdown
|
name: flutter_markdown_plus
|
||||||
sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27"
|
sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7+1"
|
version: "1.0.7"
|
||||||
flutter_riverpod:
|
flutter_riverpod:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
+2
-2
@@ -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: 1.0.0+1
|
version: 26.03.02+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0
|
sdk: ^3.11.0
|
||||||
@@ -19,11 +19,11 @@ dependencies:
|
|||||||
dio_cookie_manager: ^3.1.1
|
dio_cookie_manager: ^3.1.1
|
||||||
path_provider: ^2.1.4
|
path_provider: ^2.1.4
|
||||||
shared_preferences: ^2.3.2
|
shared_preferences: ^2.3.2
|
||||||
flutter_markdown: ^0.7.3
|
|
||||||
markdown: ^7.2.2
|
markdown: ^7.2.2
|
||||||
package_info_plus: ^8.0.0
|
package_info_plus: ^8.0.0
|
||||||
open_file: ^3.3.2
|
open_file: ^3.3.2
|
||||||
flutter_inappwebview: ^6.1.5
|
flutter_inappwebview: ^6.1.5
|
||||||
|
flutter_markdown_plus: ^1.0.7
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user