From b307f66f2cf317f8bc9c01c238f941363eb3c9ea Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Sun, 27 Sep 2026 16:06:26 +0800 Subject: [PATCH] Add PM extension (Projects, Work Logs, Comments) + Quill rich-text UI Projects: Brick-backed model with list/detail screens and nav entry. Work Logs: per-contributor time ledger, assignee running-clock + helper logged minutes with Hours/Minutes input; pause-after-save and helper-deduct prompts. Comments: threaded comments with file attachments and Quill composer. Detail screen: tabbed layout (Work Log / Comments / Activity), editable title + description via edit dialog, Quill-rendered descriptions with legacy plain-text fallback. Create dialog: fixed-width (480px max), Quill description editor. Shared QuillFieldEditor + QuillReadOnly widgets extracted for reuse. RLS: hardened activity-log actor binding, immutable creator_id/ created_at triggers, SECURITY INVOKER visibility helper, append-only work-log and scoped comment policies. Brick migration for project_id FK on programmer_tasks. Co-Authored-By: claude-flow --- .../programmer_project_adapter.g.dart | 231 +++++++++++++++ .../adapters/programmer_task_adapter.g.dart | 18 ++ lib/brick/brick.g.dart | 4 + lib/brick/db/20260927030348.migration.dart | 54 ++++ lib/brick/db/schema.g.dart | 30 +- lib/models/programmer_project.model.dart | 80 +++++ lib/models/programmer_task.model.dart | 3 + lib/models/programmer_task_comment.model.dart | 74 +++++ .../programmer_task_work_log.model.dart | 36 +++ .../programmer_projects_provider.dart | 122 ++++++++ .../programmer_task_comments_provider.dart | 71 +++++ .../programmer_task_work_logs_provider.dart | 44 +++ lib/providers/programmer_tasks_provider.dart | 32 ++ lib/routing/app_router.dart | 19 +- .../programmer_project_detail_screen.dart | 154 ++++++++++ .../programmer_projects_list_screen.dart | 207 +++++++++++++ .../programmer_task_create.dart | 238 +++++++++++++++ .../programmer_task_detail_screen.dart | 167 +++++------ .../programmer_task_edit.dart | 89 ++++++ .../programmer_task_prompts.dart | 97 ++++++ .../programmer_tasks_list_screen.dart | 130 +------- .../widgets/activity_timeline.dart | 103 +++++++ .../widgets/comment_section.dart | 280 ++++++++++++++++++ .../widgets/time_ledger_card.dart | 78 +++++ .../widgets/work_log_section.dart | 191 ++++++++++++ lib/utils/programmer_task_ledger.dart | 78 +++++ lib/utils/task_duration.dart | 9 +- lib/widgets/app_shell.dart | 10 +- lib/widgets/quill_field.dart | 144 +++++++++ ...0927130000_harden_programmer_tasks_rls.sql | 51 ++++ ..._programmer_projects_worklogs_comments.sql | 151 ++++++++++ test/programmer_task_ledger_test.dart | 102 +++++++ test/programmer_tasks_controller_test.dart | 79 +++++ 33 files changed, 2960 insertions(+), 216 deletions(-) create mode 100644 lib/brick/adapters/programmer_project_adapter.g.dart create mode 100644 lib/brick/db/20260927030348.migration.dart create mode 100644 lib/models/programmer_project.model.dart create mode 100644 lib/models/programmer_task_comment.model.dart create mode 100644 lib/models/programmer_task_work_log.model.dart create mode 100644 lib/providers/programmer_projects_provider.dart create mode 100644 lib/providers/programmer_task_comments_provider.dart create mode 100644 lib/providers/programmer_task_work_logs_provider.dart create mode 100644 lib/screens/programmer_projects/programmer_project_detail_screen.dart create mode 100644 lib/screens/programmer_projects/programmer_projects_list_screen.dart create mode 100644 lib/screens/programmer_tasks/programmer_task_create.dart create mode 100644 lib/screens/programmer_tasks/programmer_task_edit.dart create mode 100644 lib/screens/programmer_tasks/programmer_task_prompts.dart create mode 100644 lib/screens/programmer_tasks/widgets/activity_timeline.dart create mode 100644 lib/screens/programmer_tasks/widgets/comment_section.dart create mode 100644 lib/screens/programmer_tasks/widgets/time_ledger_card.dart create mode 100644 lib/screens/programmer_tasks/widgets/work_log_section.dart create mode 100644 lib/utils/programmer_task_ledger.dart create mode 100644 lib/widgets/quill_field.dart create mode 100644 supabase/migrations/20260927130000_harden_programmer_tasks_rls.sql create mode 100644 supabase/migrations/20260927140000_add_programmer_projects_worklogs_comments.sql create mode 100644 test/programmer_task_ledger_test.dart diff --git a/lib/brick/adapters/programmer_project_adapter.g.dart b/lib/brick/adapters/programmer_project_adapter.g.dart new file mode 100644 index 00000000..daa2a209 --- /dev/null +++ b/lib/brick/adapters/programmer_project_adapter.g.dart @@ -0,0 +1,231 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$ProgrammerProjectFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return ProgrammerProject( + id: data['id'] as String, + name: data['name'] as String, + description: data['description'] == null + ? null + : data['description'] as String?, + status: data['status'] as String, + creatorId: data['creator_id'] == null + ? null + : data['creator_id'] as String?, + createdAt: DateTime.parse(data['created_at'] as String), + updatedAt: DateTime.parse(data['updated_at'] as String), + ); +} + +Future> _$ProgrammerProjectToSupabase( + ProgrammerProject instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'name': instance.name, + 'description': instance.description, + 'status': instance.status, + 'creator_id': instance.creatorId, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'is_archived': instance.isArchived, + }; +} + +Future _$ProgrammerProjectFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return ProgrammerProject( + id: data['id'] as String, + name: data['name'] as String, + description: data['description'] == null + ? null + : data['description'] as String?, + status: data['status'] as String, + creatorId: data['creator_id'] == null + ? null + : data['creator_id'] as String?, + createdAt: DateTime.parse(data['created_at'] as String), + updatedAt: DateTime.parse(data['updated_at'] as String), + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$ProgrammerProjectToSqlite( + ProgrammerProject instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'name': instance.name, + 'description': instance.description, + 'status': instance.status, + 'creator_id': instance.creatorId, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'is_archived': instance.isArchived ? 1 : 0, + }; +} + +/// Construct a [ProgrammerProject] +class ProgrammerProjectAdapter + extends OfflineFirstWithSupabaseAdapter { + ProgrammerProjectAdapter(); + + @override + final supabaseTableName = 'programmer_projects'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'name': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'name', + ), + 'description': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'description', + ), + 'status': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'status', + ), + 'creatorId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'creator_id', + ), + 'createdAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'created_at', + ), + 'updatedAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'updated_at', + ), + 'isArchived': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'is_archived', + ), + }; + @override + final ignoreDuplicates = false; + @override + final uniqueFields = {}; + @override + final Map fieldsToSqliteColumns = { + 'primaryKey': const RuntimeSqliteColumnDefinition( + association: false, + columnName: '_brick_id', + iterable: false, + type: int, + ), + 'id': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'id', + iterable: false, + type: String, + ), + 'name': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'name', + iterable: false, + type: String, + ), + 'description': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'description', + iterable: false, + type: String, + ), + 'status': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'status', + iterable: false, + type: String, + ), + 'creatorId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'creator_id', + iterable: false, + type: String, + ), + 'createdAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'created_at', + iterable: false, + type: DateTime, + ), + 'updatedAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'updated_at', + iterable: false, + type: DateTime, + ), + 'isArchived': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'is_archived', + iterable: false, + type: bool, + ), + }; + @override + Future primaryKeyByUniqueColumns( + ProgrammerProject instance, + DatabaseExecutor executor, + ) async => instance.primaryKey; + @override + final String tableName = 'ProgrammerProject'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ProgrammerProjectFromSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSupabase( + ProgrammerProject input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ProgrammerProjectToSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ProgrammerProjectFromSqlite( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSqlite( + ProgrammerProject input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ProgrammerProjectToSqlite( + input, + provider: provider, + repository: repository, + ); +} diff --git a/lib/brick/adapters/programmer_task_adapter.g.dart b/lib/brick/adapters/programmer_task_adapter.g.dart index e8ae4b51..4d4a4c67 100644 --- a/lib/brick/adapters/programmer_task_adapter.g.dart +++ b/lib/brick/adapters/programmer_task_adapter.g.dart @@ -21,6 +21,9 @@ Future _$ProgrammerTaskFromSupabase( creatorId: data['creator_id'] == null ? null : data['creator_id'] as String?, + projectId: data['project_id'] == null + ? null + : data['project_id'] as String?, createdAt: DateTime.parse(data['created_at'] as String), startedAt: data['started_at'] == null ? null @@ -58,6 +61,7 @@ Future> _$ProgrammerTaskToSupabase( 'priority': instance.priority, 'assignee_id': instance.assigneeId, 'creator_id': instance.creatorId, + 'project_id': instance.projectId, 'created_at': instance.createdAt.toIso8601String(), 'started_at': instance.startedAt?.toIso8601String(), 'completed_at': instance.completedAt?.toIso8601String(), @@ -88,6 +92,9 @@ Future _$ProgrammerTaskFromSqlite( creatorId: data['creator_id'] == null ? null : data['creator_id'] as String?, + projectId: data['project_id'] == null + ? null + : data['project_id'] as String?, createdAt: DateTime.parse(data['created_at'] as String), startedAt: data['started_at'] == null ? null @@ -125,6 +132,7 @@ Future> _$ProgrammerTaskToSqlite( 'priority': instance.priority, 'assignee_id': instance.assigneeId, 'creator_id': instance.creatorId, + 'project_id': instance.projectId, 'created_at': instance.createdAt.toIso8601String(), 'started_at': instance.startedAt?.toIso8601String(), 'completed_at': instance.completedAt?.toIso8601String(), @@ -178,6 +186,10 @@ class ProgrammerTaskAdapter association: false, columnName: 'creator_id', ), + 'projectId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'project_id', + ), 'createdAt': const RuntimeSupabaseColumnDefinition( association: false, columnName: 'created_at', @@ -267,6 +279,12 @@ class ProgrammerTaskAdapter iterable: false, type: String, ), + 'projectId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'project_id', + iterable: false, + type: String, + ), 'createdAt': const RuntimeSqliteColumnDefinition( association: false, columnName: 'created_at', diff --git a/lib/brick/brick.g.dart b/lib/brick/brick.g.dart index ac6edca3..aa3e5263 100644 --- a/lib/brick/brick.g.dart +++ b/lib/brick/brick.g.dart @@ -35,6 +35,7 @@ import '../models/notification_item.model.dart'; import '../models/office.model.dart'; import '../models/pass_slip.model.dart'; import '../models/profile.model.dart'; +import '../models/programmer_project.model.dart'; import '../models/programmer_task.model.dart'; import '../models/programmer_task_activity_log.model.dart'; import '../models/service.model.dart'; @@ -58,6 +59,7 @@ part 'adapters/notification_item_adapter.g.dart'; part 'adapters/office_adapter.g.dart'; part 'adapters/pass_slip_adapter.g.dart'; part 'adapters/profile_adapter.g.dart'; +part 'adapters/programmer_project_adapter.g.dart'; part 'adapters/programmer_task_adapter.g.dart'; part 'adapters/programmer_task_activity_log_adapter.g.dart'; part 'adapters/service_adapter.g.dart'; @@ -83,6 +85,7 @@ final Map> supabaseMappings = { Office: OfficeAdapter(), PassSlip: PassSlipAdapter(), Profile: ProfileAdapter(), + ProgrammerProject: ProgrammerProjectAdapter(), ProgrammerTask: ProgrammerTaskAdapter(), ProgrammerTaskActivityLog: ProgrammerTaskActivityLogAdapter(), Service: ServiceAdapter(), @@ -110,6 +113,7 @@ final Map> sqliteMappings = { Office: OfficeAdapter(), PassSlip: PassSlipAdapter(), Profile: ProfileAdapter(), + ProgrammerProject: ProgrammerProjectAdapter(), ProgrammerTask: ProgrammerTaskAdapter(), ProgrammerTaskActivityLog: ProgrammerTaskActivityLogAdapter(), Service: ServiceAdapter(), diff --git a/lib/brick/db/20260927030348.migration.dart b/lib/brick/db/20260927030348.migration.dart new file mode 100644 index 00000000..7f843066 --- /dev/null +++ b/lib/brick/db/20260927030348.migration.dart @@ -0,0 +1,54 @@ +// GENERATED CODE EDIT WITH CAUTION +// THIS FILE **WILL NOT** BE REGENERATED +// This file should be version controlled and can be manually edited. +part of 'schema.g.dart'; + +// While migrations are intelligently created, the difference between some commands, such as +// DropTable vs. RenameTable, cannot be determined. For this reason, please review migrations after +// they are created to ensure the correct inference was made. + +// The migration version must **always** mirror the file name + +const List _migration_20260927030348_up = [ + InsertTable('ProgrammerProject'), + InsertColumn('id', Column.varchar, onTable: 'ProgrammerProject'), + InsertColumn('name', Column.varchar, onTable: 'ProgrammerProject'), + InsertColumn('description', Column.varchar, onTable: 'ProgrammerProject'), + InsertColumn('status', Column.varchar, onTable: 'ProgrammerProject'), + InsertColumn('creator_id', Column.varchar, onTable: 'ProgrammerProject'), + InsertColumn('created_at', Column.datetime, onTable: 'ProgrammerProject'), + InsertColumn('updated_at', Column.datetime, onTable: 'ProgrammerProject'), + InsertColumn('is_archived', Column.boolean, onTable: 'ProgrammerProject'), + InsertColumn('project_id', Column.varchar, onTable: 'ProgrammerTask') +]; + +const List _migration_20260927030348_down = [ + DropTable('ProgrammerProject'), + DropColumn('id', onTable: 'ProgrammerProject'), + DropColumn('name', onTable: 'ProgrammerProject'), + DropColumn('description', onTable: 'ProgrammerProject'), + DropColumn('status', onTable: 'ProgrammerProject'), + DropColumn('creator_id', onTable: 'ProgrammerProject'), + DropColumn('created_at', onTable: 'ProgrammerProject'), + DropColumn('updated_at', onTable: 'ProgrammerProject'), + DropColumn('is_archived', onTable: 'ProgrammerProject'), + DropColumn('project_id', onTable: 'ProgrammerTask') +]; + +// +// DO NOT EDIT BELOW THIS LINE +// + +@Migratable( + version: '20260927030348', + up: _migration_20260927030348_up, + down: _migration_20260927030348_down, +) +class Migration20260927030348 extends Migration { + const Migration20260927030348() + : super( + version: 20260927030348, + up: _migration_20260927030348_up, + down: _migration_20260927030348_down, + ); +} diff --git a/lib/brick/db/schema.g.dart b/lib/brick/db/schema.g.dart index 42a3b217..1ebf7fa4 100644 --- a/lib/brick/db/schema.g.dart +++ b/lib/brick/db/schema.g.dart @@ -1,20 +1,22 @@ // GENERATED CODE DO NOT EDIT // This file should be version controlled import 'package:brick_sqlite/db.dart'; -part '20260926162926.migration.dart'; +part '20260927030348.migration.dart'; part '20260411041808.migration.dart'; part '20260411042152.migration.dart'; +part '20260926162926.migration.dart'; /// All intelligently-generated migrations from all `@Migratable` classes on disk final migrations = { - const Migration20260926162926(), + const Migration20260927030348(), const Migration20260411041808(), const Migration20260411042152(), + const Migration20260926162926(), }; /// A consumable database structure including the latest generated migration. final schema = Schema( - 20260411042152, + 20260926162926, generatorVersion: 1, tables: { SchemaTable( @@ -336,6 +338,27 @@ final schema = Schema( }, indices: {}, ), + SchemaTable( + 'ProgrammerProject', + columns: { + SchemaColumn( + '_brick_id', + Column.integer, + autoincrement: true, + nullable: false, + isPrimaryKey: true, + ), + SchemaColumn('id', Column.varchar), + SchemaColumn('name', Column.varchar), + SchemaColumn('description', Column.varchar), + SchemaColumn('status', Column.varchar), + SchemaColumn('creator_id', Column.varchar), + SchemaColumn('created_at', Column.datetime), + SchemaColumn('updated_at', Column.datetime), + SchemaColumn('is_archived', Column.boolean), + }, + indices: {}, + ), SchemaTable( 'ProgrammerTask', columns: { @@ -354,6 +377,7 @@ final schema = Schema( SchemaColumn('priority', Column.integer), SchemaColumn('assignee_id', Column.varchar), SchemaColumn('creator_id', Column.varchar), + SchemaColumn('project_id', Column.varchar), SchemaColumn('created_at', Column.datetime), SchemaColumn('started_at', Column.datetime), SchemaColumn('completed_at', Column.datetime), diff --git a/lib/models/programmer_project.model.dart b/lib/models/programmer_project.model.dart new file mode 100644 index 00000000..31467aad --- /dev/null +++ b/lib/models/programmer_project.model.dart @@ -0,0 +1,80 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_sqlite/brick_sqlite.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +import '../utils/app_time.dart'; + +/// Status of a programmer project. +class ProgrammerProjectStatus { + static const active = 'active'; + static const archived = 'archived'; + + static const all = [active, archived]; + + static String label(String status) { + switch (status) { + case active: + return 'Active'; + case archived: + return 'Archived'; + default: + return status; + } + } +} + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'programmer_projects'), +) +class ProgrammerProject extends OfflineFirstWithSupabaseModel { + final String id; + final String name; + final String? description; + final String status; + final String? creatorId; + final DateTime createdAt; + final DateTime updatedAt; + + ProgrammerProject({ + required this.id, + required this.name, + this.description, + required this.status, + this.creatorId, + required this.createdAt, + required this.updatedAt, + }); + + bool get isArchived => status == ProgrammerProjectStatus.archived; + + @Supabase(ignore: true) + @Sqlite(ignore: true) + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProgrammerProject && + runtimeType == other.runtimeType && + id == other.id && + name == other.name && + status == other.status && + updatedAt == other.updatedAt; + + @Supabase(ignore: true) + @Sqlite(ignore: true) + @override + int get hashCode => Object.hash(id, name, status, updatedAt); + + factory ProgrammerProject.fromMap(Map map) { + return ProgrammerProject( + id: map['id'].toString(), + name: map['name'] as String? ?? '', + description: map['description'] as String?, + status: map['status'] as String? ?? ProgrammerProjectStatus.active, + creatorId: map['creator_id']?.toString(), + createdAt: AppTime.parse(map['created_at'] as String), + updatedAt: map['updated_at'] == null + ? AppTime.parse(map['created_at'] as String) + : AppTime.parse(map['updated_at'] as String), + ); + } +} diff --git a/lib/models/programmer_task.model.dart b/lib/models/programmer_task.model.dart index 5b980075..97e71f83 100644 --- a/lib/models/programmer_task.model.dart +++ b/lib/models/programmer_task.model.dart @@ -74,6 +74,7 @@ class ProgrammerTask extends OfflineFirstWithSupabaseModel { final int priority; final String? assigneeId; final String? creatorId; + final String? projectId; final DateTime createdAt; final DateTime? startedAt; final DateTime? completedAt; @@ -90,6 +91,7 @@ class ProgrammerTask extends OfflineFirstWithSupabaseModel { this.priority = 1, this.assigneeId, this.creatorId, + this.projectId, required this.createdAt, this.startedAt, this.completedAt, @@ -126,6 +128,7 @@ class ProgrammerTask extends OfflineFirstWithSupabaseModel { priority: (map['priority'] as num?)?.toInt() ?? 1, assigneeId: map['assignee_id']?.toString(), creatorId: map['creator_id']?.toString(), + projectId: map['project_id']?.toString(), createdAt: AppTime.parse(map['created_at'] as String), startedAt: map['started_at'] == null ? null diff --git a/lib/models/programmer_task_comment.model.dart b/lib/models/programmer_task_comment.model.dart new file mode 100644 index 00000000..5823d75f --- /dev/null +++ b/lib/models/programmer_task_comment.model.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; + +import '../utils/app_time.dart'; + +/// One file attached to a comment (stored in the task_attachments bucket). +class ProgrammerCommentAttachment { + final String path; + final String name; + + const ProgrammerCommentAttachment({required this.path, required this.name}); + + Map toJson() => {'path': path, 'name': name}; + + factory ProgrammerCommentAttachment.fromJson(Map json) { + return ProgrammerCommentAttachment( + path: json['path']?.toString() ?? '', + name: json['name']?.toString() ?? '', + ); + } +} + +/// A comment on a programmer task (plain, online-first model). Any user who can +/// see the task may comment and attach files, whether or not they are the +/// assignee. +class ProgrammerTaskComment { + final String id; + final String taskId; + final String authorId; + final String body; + final List attachments; + final DateTime createdAt; + + ProgrammerTaskComment({ + required this.id, + required this.taskId, + required this.authorId, + required this.body, + this.attachments = const [], + required this.createdAt, + }); + + factory ProgrammerTaskComment.fromMap(Map map) { + return ProgrammerTaskComment( + id: map['id'].toString(), + taskId: map['task_id'].toString(), + authorId: map['author_id'].toString(), + body: map['body'] as String? ?? '', + attachments: _parseAttachments(map['attachments']), + createdAt: AppTime.parse(map['created_at'] as String), + ); + } + + static List _parseAttachments(dynamic raw) { + List list; + if (raw is List) { + list = raw; + } else if (raw is String && raw.isNotEmpty) { + try { + final decoded = jsonDecode(raw); + list = decoded is List ? decoded : const []; + } catch (_) { + list = const []; + } + } else { + return const []; + } + return list + .whereType() + .map((e) => ProgrammerCommentAttachment.fromJson( + e.map((k, v) => MapEntry(k.toString(), v)), + )) + .toList(); + } +} diff --git a/lib/models/programmer_task_work_log.model.dart b/lib/models/programmer_task_work_log.model.dart new file mode 100644 index 00000000..39c28c33 --- /dev/null +++ b/lib/models/programmer_task_work_log.model.dart @@ -0,0 +1,36 @@ +import '../utils/app_time.dart'; + +/// A work-log entry on a programmer task (plain, online-first model). +/// +/// The assignee logs progress notes with [minutes] == null (their time comes +/// from the running clock). A non-assignee "helper" logs with [minutes] set — +/// their contributed time, which feeds the per-contributor time ledger and can +/// be deducted from the helper's own running task. +class ProgrammerTaskWorkLog { + final String id; + final String taskId; + final String authorId; + final String description; + final int? minutes; + final DateTime createdAt; + + ProgrammerTaskWorkLog({ + required this.id, + required this.taskId, + required this.authorId, + required this.description, + this.minutes, + required this.createdAt, + }); + + factory ProgrammerTaskWorkLog.fromMap(Map map) { + return ProgrammerTaskWorkLog( + id: map['id'].toString(), + taskId: map['task_id'].toString(), + authorId: map['author_id'].toString(), + description: map['description'] as String? ?? '', + minutes: (map['minutes'] as num?)?.toInt(), + createdAt: AppTime.parse(map['created_at'] as String), + ); + } +} diff --git a/lib/providers/programmer_projects_provider.dart b/lib/providers/programmer_projects_provider.dart new file mode 100644 index 00000000..ec20a754 --- /dev/null +++ b/lib/providers/programmer_projects_provider.dart @@ -0,0 +1,122 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:uuid/uuid.dart'; + +import '../brick/cache_helpers.dart'; +import '../models/programmer_project.model.dart'; +import 'profile_provider.dart'; +import 'realtime_controller.dart'; +import 'stream_recovery.dart'; +import 'supabase_provider.dart'; + +void _sortProjects(List projects) { + projects.sort((a, b) { + // Active before archived, then alphabetical. + if (a.isArchived != b.isArchived) return a.isArchived ? 1 : -1; + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + }); +} + +/// All programmer projects (offline-readable via Brick cache). +final programmerProjectsProvider = StreamProvider>((ref) { + final userId = ref.watch(currentUserIdProvider); + if (userId == null) return const Stream.empty(); + final client = ref.watch(supabaseClientProvider); + + final wrapper = StreamRecoveryWrapper( + stream: client + .from('programmer_projects') + .stream(primaryKey: ['id']).order('created_at', ascending: false), + onPollData: () async { + final data = await client + .from('programmer_projects') + .select() + .order('created_at', ascending: false); + return data.map(ProgrammerProject.fromMap).toList(); + }, + fromMap: ProgrammerProject.fromMap, + channelName: 'programmer_projects', + onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + onOfflineData: () async { + final all = await cachedListFromBrick(); + _sortProjects(all); + return all; + }, + onCacheMirror: (rows) => + mirrorBatchToBrick(rows, tag: 'programmer_projects'), + ); + + ref.onDispose(wrapper.dispose); + return wrapper.stream.map((result) { + final list = List.from(result.data); + _sortProjects(list); + return list; + }); +}); + +/// Active projects only — used by the task form's project picker. +final activeProgrammerProjectsProvider = Provider>((ref) { + final all = ref.watch(programmerProjectsProvider).valueOrNull ?? const []; + return all.where((p) => !p.isArchived).toList(); +}); + +final programmerProjectByIdProvider = + Provider.family((ref, id) { + final projects = ref.watch(programmerProjectsProvider).valueOrNull; + if (projects == null) return null; + try { + return projects.firstWhere((p) => p.id == id); + } catch (_) { + return null; + } +}); + +final programmerProjectsControllerProvider = + Provider((ref) { + return ProgrammerProjectsController(ref.watch(supabaseClientProvider)); +}); + +/// Projects are created/edited online (inline from the task form or the +/// projects screen). Reads fall back to the Brick cache when offline. +class ProgrammerProjectsController { + ProgrammerProjectsController(this._client); + final dynamic _client; + + Future createProject({ + required String name, + String? description, + }) async { + final userId = _client.auth.currentUser?.id; + if (userId == null) throw Exception('Not authenticated'); + final id = const Uuid().v4(); + await _client.from('programmer_projects').insert({ + 'id': id, + 'name': name, + 'description': ?description, + 'creator_id': userId, + }); + return id; + } + + Future updateProject({ + required String projectId, + String? name, + String? description, + }) async { + final updates = {}; + if (name != null) updates['name'] = name; + if (description != null) updates['description'] = description; + if (updates.isEmpty) return; + await _client.from('programmer_projects').update(updates).eq('id', projectId); + } + + Future setArchived({ + required String projectId, + required bool archived, + }) async { + await _client.from('programmer_projects').update({ + 'status': archived + ? ProgrammerProjectStatus.archived + : ProgrammerProjectStatus.active, + }).eq('id', projectId); + } +} diff --git a/lib/providers/programmer_task_comments_provider.dart b/lib/providers/programmer_task_comments_provider.dart new file mode 100644 index 00000000..8685126d --- /dev/null +++ b/lib/providers/programmer_task_comments_provider.dart @@ -0,0 +1,71 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:uuid/uuid.dart'; + +import '../models/programmer_task_comment.model.dart'; +import 'supabase_provider.dart'; + +/// Bucket reused for programmer-task comment attachments (public read; +/// authenticated write; owner/privileged delete — already hardened by +/// 20260926120000_task_attachment_write_hardening.sql). +const _attachmentBucket = 'task_attachments'; + +/// A file selected for upload with a comment. +typedef CommentUpload = ({String name, Uint8List bytes}); + +/// Comments for a task (newest first), streamed live. +final programmerTaskCommentsProvider = + StreamProvider.family, String>((ref, taskId) { + final client = ref.watch(supabaseClientProvider); + return client + .from('programmer_task_comments') + .stream(primaryKey: ['id']) + .eq('task_id', taskId) + .order('created_at', ascending: false) + .map((rows) => rows.map(ProgrammerTaskComment.fromMap).toList()); +}); + +final programmerTaskCommentsControllerProvider = + Provider((ref) { + return ProgrammerTaskCommentsController(ref.watch(supabaseClientProvider)); +}); + +class ProgrammerTaskCommentsController { + ProgrammerTaskCommentsController(this._client); + final dynamic _client; + + /// Public URL for a stored attachment path. + String attachmentUrl(String path) => + _client.storage.from(_attachmentBucket).getPublicUrl(path); + + /// Adds a comment, uploading any [files] to the attachment bucket first. + /// Requires either a non-empty [body] or at least one file. + Future addComment({ + required String taskId, + required String body, + List files = const [], + }) async { + final userId = _client.auth.currentUser?.id; + if (userId == null) throw Exception('Not authenticated'); + + final attachments = >[]; + for (final file in files) { + final path = + 'programmer_tasks/$taskId/comments/${const Uuid().v4()}_${file.name}'; + await _client.storage.from(_attachmentBucket).uploadBinary( + path, + file.bytes, + fileOptions: const FileOptions(upsert: true), + ); + attachments.add({'path': path, 'name': file.name}); + } + + await _client.from('programmer_task_comments').insert({ + 'task_id': taskId, + 'author_id': userId, + 'body': body, + 'attachments': attachments, + }); + } +} diff --git a/lib/providers/programmer_task_work_logs_provider.dart b/lib/providers/programmer_task_work_logs_provider.dart new file mode 100644 index 00000000..09f5f9e6 --- /dev/null +++ b/lib/providers/programmer_task_work_logs_provider.dart @@ -0,0 +1,44 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/programmer_task_work_log.model.dart'; +import 'supabase_provider.dart'; + +/// Work-log entries for a task (newest first), streamed live. +final programmerTaskWorkLogsProvider = + StreamProvider.family, String>((ref, taskId) { + final client = ref.watch(supabaseClientProvider); + return client + .from('programmer_task_work_logs') + .stream(primaryKey: ['id']) + .eq('task_id', taskId) + .order('created_at', ascending: false) + .map((rows) => rows.map(ProgrammerTaskWorkLog.fromMap).toList()); +}); + +final programmerTaskWorkLogsControllerProvider = + Provider((ref) { + return ProgrammerTaskWorkLogsController(ref.watch(supabaseClientProvider)); +}); + +class ProgrammerTaskWorkLogsController { + ProgrammerTaskWorkLogsController(this._client); + final dynamic _client; + + /// Adds a work-log entry. [minutes] is null for the assignee's progress notes + /// (their time comes from the running clock) and set for a helper's + /// Time Consumed. + Future addWorkLog({ + required String taskId, + required String description, + int? minutes, + }) async { + final userId = _client.auth.currentUser?.id; + if (userId == null) throw Exception('Not authenticated'); + await _client.from('programmer_task_work_logs').insert({ + 'task_id': taskId, + 'author_id': userId, + 'description': description, + 'minutes': ?minutes, + }); + } +} diff --git a/lib/providers/programmer_tasks_provider.dart b/lib/providers/programmer_tasks_provider.dart index 88080985..1dbe8e76 100644 --- a/lib/providers/programmer_tasks_provider.dart +++ b/lib/providers/programmer_tasks_provider.dart @@ -347,6 +347,7 @@ class ProgrammerTasksController { required String category, String? description, String? assigneeId, + String? projectId, int priority = 1, }) async { final userId = _client.auth.currentUser?.id; @@ -363,6 +364,7 @@ class ProgrammerTasksController { 'priority': priority, 'assignee_id': assignee, 'creator_id': userId, + 'project_id': ?projectId, }; try { @@ -384,6 +386,7 @@ class ProgrammerTasksController { priority: priority, assigneeId: assignee, creatorId: userId, + projectId: projectId, createdAt: now, updatedAt: now, ); @@ -481,6 +484,33 @@ class ProgrammerTasksController { await _insertLog(taskId, 'updated', meta: {'fields': updates.keys.toList()}); } + /// Assigns the task to a project (pass null to clear it). + Future setProject({ + required String taskId, + required String? projectId, + }) async { + await _updateTaskRow(taskId, {'project_id': projectId}); + await _insertLog(taskId, 'updated', meta: { + 'fields': ['project_id'], + }); + } + + /// Records a time adjustment (negative [seconds]) against a task's worked + /// duration — e.g. when the current user helped on another task and chose to + /// deduct that time from their own running task. + Future addAdjustment({ + required String taskId, + required int seconds, + String? reason, + String? sourceTaskId, + }) async { + await _insertLog(taskId, 'adjustment', meta: { + 'seconds': seconds, + 'reason': ?reason, + 'source_task_id': ?sourceTaskId, + }); + } + /// Applies a field patch to a task row. When [onlyIfStartNull] is set, /// `started_at` is dropped if the task already has one (preserves the first /// execution start across pause/resume cycles). @@ -578,6 +608,8 @@ ProgrammerTask _applyUpdateFields(ProgrammerTask t, Map f) { assigneeId: f.containsKey('assignee_id') ? f['assignee_id'] as String? : t.assigneeId, creatorId: t.creatorId, + projectId: + f.containsKey('project_id') ? f['project_id'] as String? : t.projectId, createdAt: t.createdAt, startedAt: f.containsKey('started_at') ? parseOpt('started_at') : t.startedAt, completedAt: diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index ed388b6c..cc9d9bd8 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -32,6 +32,8 @@ import '../screens/it_service_requests/it_service_requests_list_screen.dart'; import '../screens/it_service_requests/it_service_request_detail_screen.dart'; import '../screens/programmer_tasks/programmer_tasks_list_screen.dart'; import '../screens/programmer_tasks/programmer_task_detail_screen.dart'; +import '../screens/programmer_projects/programmer_projects_list_screen.dart'; +import '../screens/programmer_projects/programmer_project_detail_screen.dart'; import '../screens/network_map/network_map_overview_screen.dart'; import '../screens/network_map/network_map_site_screen.dart'; import '../screens/network_map/network_map_device_screen.dart'; @@ -96,7 +98,7 @@ final appRouterProvider = Provider((ref) { role == 'dispatcher' || role == 'programmer'; final isProgrammerTasksRoute = - state.matchedLocation.startsWith('/programmer-tasks'); + state.matchedLocation.startsWith('/programmer-'); final hasProgrammerTasksAccess = role == 'admin' || role == 'programmer'; @@ -231,6 +233,21 @@ final appRouterProvider = Provider((ref) { ), ], ), + GoRoute( + path: '/programmer-projects', + pageBuilder: _page(const ProgrammerProjectsListScreen()), + routes: [ + GoRoute( + path: ':id', + pageBuilder: (context, state) => M3ContainerTransformPage( + key: state.pageKey, + child: ProgrammerProjectDetailScreen( + projectId: state.pathParameters['id'] ?? '', + ), + ), + ), + ], + ), GoRoute( path: '/announcements', pageBuilder: _page(const AnnouncementsScreen()), diff --git a/lib/screens/programmer_projects/programmer_project_detail_screen.dart b/lib/screens/programmer_projects/programmer_project_detail_screen.dart new file mode 100644 index 00000000..3cb4153e --- /dev/null +++ b/lib/screens/programmer_projects/programmer_project_detail_screen.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../models/programmer_project.model.dart'; +import '../../models/programmer_task.model.dart'; +import '../../providers/programmer_projects_provider.dart'; +import '../../providers/programmer_tasks_provider.dart'; +import '../../theme/app_status_colors.dart'; +import '../../widgets/app_section_header.dart'; +import '../../widgets/m3_card.dart'; +import '../../widgets/responsive_body.dart'; +import '../../widgets/status_pill.dart'; +import '../programmer_tasks/programmer_task_create.dart'; +import '../programmer_tasks/programmer_task_status_kind.dart'; + +class ProgrammerProjectDetailScreen extends ConsumerWidget { + const ProgrammerProjectDetailScreen({super.key, required this.projectId}); + + final String projectId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final project = ref.watch(programmerProjectByIdProvider(projectId)); + final tasks = (ref.watch(programmerTasksProvider).valueOrNull ?? const []) + .where((t) => t.projectId == projectId) + .toList(); + + return Scaffold( + appBar: AppBar( + title: Text(project?.name ?? 'Project'), + actions: [ + if (project != null) + IconButton( + tooltip: project.isArchived ? 'Unarchive' : 'Archive', + icon: Icon(project.isArchived + ? Icons.unarchive_outlined + : Icons.archive_outlined), + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + await ref.read(programmerProjectsControllerProvider).setArchived( + projectId: projectId, + archived: !project.isArchived, + ); + messenger.showSnackBar(SnackBar( + content: Text( + project.isArchived ? 'Project unarchived' : 'Project archived'), + )); + }, + ), + ], + ), + floatingActionButton: project == null + ? null + : FloatingActionButton.extended( + heroTag: 'project_new_task', + onPressed: () => showCreateProgrammerTaskDialog( + context, + ref, + initialProjectId: projectId, + ), + icon: const Icon(Icons.add), + label: const Text('New Task'), + ), + body: project == null + ? const Center(child: CircularProgressIndicator()) + : ResponsiveBody( + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 16), + children: [ + M3Card.elevated( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(project.name, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.w700)), + ), + StatusPill.kind( + project.isArchived + ? StatusKind.neutral + : StatusKind.success, + label: ProgrammerProjectStatus.label( + project.status), + ), + ], + ), + if (project.description != null && + project.description!.trim().isNotEmpty) ...[ + const SizedBox(height: 12), + Text(project.description!, + style: Theme.of(context).textTheme.bodyMedium), + ], + ], + ), + ), + ), + const SizedBox(height: 24), + AppSectionHeader('Tasks (${tasks.length})'), + if (tasks.isEmpty) + Text('No tasks in this project yet.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: + Theme.of(context).colorScheme.onSurfaceVariant)) + else + for (final t in tasks) _ProjectTaskTile(task: t), + const SizedBox(height: 80), + ], + ), + ), + ); + } +} + +class _ProjectTaskTile extends StatelessWidget { + const _ProjectTaskTile({required this.task}); + + final ProgrammerTask task; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: M3Card.outlined( + onTap: () => context.go('/programmer-tasks/${task.id}'), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + child: Text(task.title, + style: Theme.of(context).textTheme.bodyLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis), + ), + const SizedBox(width: 8), + StatusPill.kind( + programmerTaskStatusKind(task.status), + label: ProgrammerTaskStatus.label(task.status), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/programmer_projects/programmer_projects_list_screen.dart b/lib/screens/programmer_projects/programmer_projects_list_screen.dart new file mode 100644 index 00000000..6cd5fd2a --- /dev/null +++ b/lib/screens/programmer_projects/programmer_projects_list_screen.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../models/programmer_project.model.dart'; +import '../../providers/programmer_projects_provider.dart'; +import '../../providers/programmer_tasks_provider.dart'; +import '../../theme/app_status_colors.dart'; +import '../../theme/m3_motion.dart'; +import '../../widgets/app_page_header.dart'; +import '../../widgets/app_state_view.dart'; +import '../../widgets/m3_card.dart'; +import '../../widgets/reconnect_overlay.dart'; +import '../../widgets/responsive_body.dart'; +import '../../widgets/status_pill.dart'; + +class ProgrammerProjectsListScreen extends ConsumerWidget { + const ProgrammerProjectsListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final projectsAsync = ref.watch(programmerProjectsProvider); + final tasks = ref.watch(programmerTasksProvider).valueOrNull ?? const []; + final taskCountByProject = {}; + for (final t in tasks) { + if (t.projectId != null) { + taskCountByProject[t.projectId!] = + (taskCountByProject[t.projectId!] ?? 0) + 1; + } + } + + return Stack( + children: [ + ResponsiveBody( + maxWidth: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const AppPageHeader( + title: 'Projects', + subtitle: 'Group programmer tasks by project', + ), + Expanded( + child: Builder( + builder: (context) { + if (projectsAsync.hasError && !projectsAsync.hasValue) { + return AppErrorView( + error: projectsAsync.error!, + onRetry: () => + ref.invalidate(programmerProjectsProvider), + ); + } + if (!projectsAsync.hasValue && projectsAsync.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + final projects = projectsAsync.valueOrNull ?? const []; + if (projects.isEmpty) { + return const AppEmptyView( + icon: Icons.folder_outlined, + title: 'No projects yet', + subtitle: 'Create one with the + button, or add a ' + 'project on the fly from a task.', + ); + } + return ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: projects.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (context, i) { + final p = projects[i]; + return _ProjectCard( + project: p, + taskCount: taskCountByProject[p.id] ?? 0, + ); + }, + ); + }, + ), + ), + ], + ), + ), + Positioned( + right: 16, + bottom: 16, + child: M3ExpandedFab( + heroTag: 'create_programmer_project', + onPressed: () => _showCreateDialog(context, ref), + icon: const Icon(Icons.add), + label: const Text('New Project'), + ), + ), + const ReconnectIndicator(), + ], + ); + } + + Future _showCreateDialog(BuildContext context, WidgetRef ref) async { + final nameCtrl = TextEditingController(); + final descCtrl = TextEditingController(); + final ok = await showDialog( + context: context, + builder: (context) => AlertDialog( + semanticLabel: 'New project', + title: const Text('New Project'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameCtrl, + autofocus: true, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration(labelText: 'Name'), + ), + const SizedBox(height: 12), + TextField( + controller: descCtrl, + minLines: 2, + maxLines: 4, + textCapitalization: TextCapitalization.sentences, + decoration: + const InputDecoration(labelText: 'Description (optional)'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Create'), + ), + ], + ), + ); + final name = nameCtrl.text.trim(); + final description = descCtrl.text.trim(); + nameCtrl.dispose(); + descCtrl.dispose(); + if (ok != true || name.isEmpty || !context.mounted) return; + final messenger = ScaffoldMessenger.of(context); + try { + final id = await ref.read(programmerProjectsControllerProvider).createProject( + name: name, + description: description.isEmpty ? null : description, + ); + if (!context.mounted) return; + context.go('/programmer-projects/$id'); + } catch (e) { + messenger + .showSnackBar(SnackBar(content: Text('Could not create project: $e'))); + } + } +} + +class _ProjectCard extends StatelessWidget { + const _ProjectCard({required this.project, required this.taskCount}); + + final ProgrammerProject project; + final int taskCount; + + @override + Widget build(BuildContext context) { + final tt = Theme.of(context).textTheme; + final cs = Theme.of(context).colorScheme; + return M3Card.outlined( + onTap: () => context.go('/programmer-projects/${project.id}'), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(project.name, + style: tt.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ), + const SizedBox(width: 8), + StatusPill.kind( + project.isArchived ? StatusKind.neutral : StatusKind.success, + label: ProgrammerProjectStatus.label(project.status), + ), + ], + ), + if (project.description != null && + project.description!.trim().isNotEmpty) ...[ + const SizedBox(height: 6), + Text(project.description!, + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), + maxLines: 2, + overflow: TextOverflow.ellipsis), + ], + const SizedBox(height: 8), + Text('$taskCount task${taskCount == 1 ? '' : 's'}', + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant)), + ], + ), + ), + ); + } +} diff --git a/lib/screens/programmer_tasks/programmer_task_create.dart b/lib/screens/programmer_tasks/programmer_task_create.dart new file mode 100644 index 00000000..a416ef3e --- /dev/null +++ b/lib/screens/programmer_tasks/programmer_task_create.dart @@ -0,0 +1,238 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../models/profile.model.dart'; +import '../../models/programmer_project.model.dart'; +import '../../models/programmer_task.model.dart'; +import '../../providers/profile_provider.dart'; +import '../../providers/programmer_projects_provider.dart'; +import '../../providers/programmer_tasks_provider.dart'; +import '../../widgets/quill_field.dart'; + +/// Shows the "New Programmer Task" dialog and, on success, navigates to the new +/// task's detail. Shared by the tasks list FAB and the project detail screen +/// (pass [initialProjectId] to pre-select a project). +Future showCreateProgrammerTaskDialog( + BuildContext context, + WidgetRef ref, { + String? initialProjectId, +}) async { + final userId = ref.read(currentUserIdProvider); + final profiles = ref.read(profilesProvider).valueOrNull ?? const []; + final eligible = profiles + .where((p) => p.role == 'programmer' || p.role == 'admin') + .toList() + ..sort((a, b) => a.fullName.compareTo(b.fullName)); + final projectOptions = + List.from(ref.read(activeProgrammerProjectsProvider)); + + final titleCtrl = TextEditingController(); + final descQuill = quillControllerFromDelta(null); + var category = ProgrammerTaskCategory.softwareDevelopment; + var assigneeId = userId; + var projectId = initialProjectId; + + final created = await showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setDialogState) { + final dialogWidth = + math.min(480.0, MediaQuery.of(context).size.width - 48); + return AlertDialog( + semanticLabel: 'New programmer task', + title: const Text('New Programmer Task'), + content: SizedBox( + width: dialogWidth, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: titleCtrl, + autofocus: true, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'Title', + hintText: 'What are you working on?', + ), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: Text('Description (optional)', + style: Theme.of(context).textTheme.bodySmall), + ), + const SizedBox(height: 4), + QuillFieldEditor( + controller: descQuill, + placeholder: 'Describe the task…', + height: 140, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: category, + decoration: const InputDecoration(labelText: 'Category'), + items: [ + for (final c in ProgrammerTaskCategory.all) + DropdownMenuItem(value: c, child: Text(c)), + ], + onChanged: (v) => + setDialogState(() => category = v ?? category), + ), + if (eligible.isNotEmpty) ...[ + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: assigneeId, + decoration: const InputDecoration(labelText: 'Assignee'), + items: [ + for (final p in eligible) + DropdownMenuItem( + value: p.id, + child: Text( + p.id == userId ? '${p.fullName} (me)' : p.fullName, + ), + ), + ], + onChanged: (v) => + setDialogState(() => assigneeId = v ?? assigneeId), + ), + ], + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: projectOptions.any((p) => p.id == projectId) + ? projectId + : null, + decoration: + const InputDecoration(labelText: 'Project (optional)'), + items: [ + const DropdownMenuItem( + value: null, child: Text('No project')), + for (final p in projectOptions) + DropdownMenuItem( + value: p.id, child: Text(p.name)), + ], + onChanged: (v) => setDialogState(() => projectId = v), + ), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () async { + final proj = await _promptNewProject(context, ref); + if (proj != null) { + setDialogState(() { + projectOptions.add(proj); + projectId = proj.id; + }); + } + }, + icon: const Icon(Icons.add, size: 18), + label: const Text('New project'), + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Create'), + ), + ], + ); + }, + ); + }, + ); + + if (created != true) { + titleCtrl.dispose(); + descQuill.dispose(); + return; + } + + final title = titleCtrl.text.trim(); + titleCtrl.dispose(); + final description = + quillPlainText(descQuill).isEmpty ? null : quillToDeltaJson(descQuill); + descQuill.dispose(); + if (title.isEmpty) return; + if (!context.mounted) return; + + final messenger = ScaffoldMessenger.of(context); + try { + final id = await ref.read(programmerTasksControllerProvider).createTask( + title: title, + category: category, + description: description, + assigneeId: assigneeId, + projectId: projectId, + ); + if (!context.mounted) return; + context.go('/programmer-tasks/$id'); + } catch (e) { + messenger.showSnackBar(SnackBar(content: Text('Could not create task: $e'))); + } +} + +/// Prompts for a project name, creates it, and returns a lightweight model for +/// the picker (the realtime insert with the same id syncs shortly after). +Future _promptNewProject( + BuildContext context, + WidgetRef ref, +) async { + final messenger = ScaffoldMessenger.of(context); + final nameCtrl = TextEditingController(); + final name = await showDialog( + context: context, + builder: (context) => AlertDialog( + semanticLabel: 'New project', + title: const Text('New project'), + content: TextField( + controller: nameCtrl, + autofocus: true, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration(labelText: 'Project name'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(nameCtrl.text.trim()), + child: const Text('Create'), + ), + ], + ), + ); + nameCtrl.dispose(); + if (name == null || name.isEmpty) return null; + try { + final id = await ref + .read(programmerProjectsControllerProvider) + .createProject(name: name); + final now = DateTime.now().toUtc(); + return ProgrammerProject( + id: id, + name: name, + status: ProgrammerProjectStatus.active, + createdAt: now, + updatedAt: now, + ); + } catch (e) { + messenger.showSnackBar( + SnackBar(content: Text('Could not create project: $e')), + ); + return null; + } +} diff --git a/lib/screens/programmer_tasks/programmer_task_detail_screen.dart b/lib/screens/programmer_tasks/programmer_task_detail_screen.dart index ac5df3f2..49b85d38 100644 --- a/lib/screens/programmer_tasks/programmer_task_detail_screen.dart +++ b/lib/screens/programmer_tasks/programmer_task_detail_screen.dart @@ -5,16 +5,24 @@ import '../../models/profile.model.dart'; import '../../models/programmer_task.model.dart'; import '../../models/programmer_task_activity_log.model.dart'; import '../../providers/profile_provider.dart'; +import '../../providers/programmer_projects_provider.dart'; +import '../../providers/programmer_task_work_logs_provider.dart'; import '../../providers/programmer_tasks_provider.dart'; import '../../utils/app_time.dart'; +import '../../utils/programmer_task_ledger.dart'; import '../../utils/task_duration.dart'; -import '../../widgets/app_section_header.dart'; import '../../widgets/m3_card.dart'; +import '../../widgets/quill_field.dart'; import '../../widgets/responsive_body.dart'; import '../../widgets/status_pill.dart'; import '../../widgets/tech_chip.dart'; import 'programmer_task_actions.dart'; +import 'programmer_task_edit.dart'; import 'programmer_task_status_kind.dart'; +import 'widgets/activity_timeline.dart'; +import 'widgets/comment_section.dart'; +import 'widgets/time_ledger_card.dart'; +import 'widgets/work_log_section.dart'; /// Lifecycle events, newest-first, that determine the live running/paused state. const _lifecycleActions = ['started', 'paused', 'resumed', 'completed', 'cancelled']; @@ -37,12 +45,18 @@ class ProgrammerTaskDetailScreen extends ConsumerWidget { appBar: AppBar( title: Text(task?.title ?? 'Programmer Task'), actions: [ - if (task != null && !task.isTerminal && _canAct(task, userId, role)) + if (task != null && !task.isTerminal && _canAct(task, userId, role)) ...[ + IconButton( + tooltip: 'Edit', + icon: const Icon(Icons.edit_outlined), + onPressed: () => showEditProgrammerTaskDialog(context, ref, task), + ), IconButton( tooltip: 'Reassign', icon: const Icon(Icons.person_add_alt_1_outlined), onPressed: () => _reassign(context, ref, task, profiles, userId), ), + ], ], ), body: task == null @@ -99,22 +113,45 @@ class _DetailBody extends ConsumerWidget { ProgrammerTaskStatus.cancelled => task.cancelledAt ?? AppTime.now(), _ => AppTime.now(), }; + // Sum any time deducted (helped elsewhere) from the activity log. + var adjustmentSeconds = 0; + for (final l in logs) { + if (l.actionType == 'adjustment') { + adjustmentSeconds += (l.meta?['seconds'] as num?)?.toInt() ?? 0; + } + } final worked = computeEffectiveDuration( fallbackStartedAt: task.startedAt, endAt: endAt, events: logs .map((l) => (createdAt: l.createdAt, actionType: l.actionType)) .toList(), + adjustment: Duration(seconds: adjustmentSeconds), + ); + + final workLogs = + ref.watch(programmerTaskWorkLogsProvider(task.id)).valueOrNull ?? const []; + final ledger = computeTaskLedger( + assigneeId: task.assigneeId, + assigneeDuration: worked, + workLogs: workLogs, ); final assignee = task.assigneeId == null ? null : profileById[task.assigneeId]; + final project = task.projectId == null + ? null + : ref.watch(programmerProjectByIdProvider(task.projectId!)); final canAct = _canAct(task, userId, role); + final isAssignee = task.assigneeId == userId; - return ResponsiveBody( - child: ListView( - padding: const EdgeInsets.symmetric(vertical: 16), - children: [ + return DefaultTabController( + length: 3, + child: ResponsiveBody( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 16), M3Card.elevated( child: Padding( padding: const EdgeInsets.all(16), @@ -145,6 +182,8 @@ class _DetailBody extends ConsumerWidget { crossAxisAlignment: WrapCrossAlignment.center, children: [ TechChip(task.category, icon: Icons.category_outlined), + if (project != null) + TechChip(project.name, icon: Icons.folder_outlined), if (assignee != null) Row( mainAxisSize: MainAxisSize.min, @@ -164,7 +203,7 @@ class _DetailBody extends ConsumerWidget { if (task.description != null && task.description!.trim().isNotEmpty) ...[ const SizedBox(height: 14), - Text(task.description!, style: tt.bodyMedium), + QuillReadOnly(task.description!, style: tt.bodyMedium), ], const SizedBox(height: 14), Row( @@ -172,7 +211,7 @@ class _DetailBody extends ConsumerWidget { Icon(Icons.timer_outlined, size: 18, color: cs.primary), const SizedBox(width: 8), Text( - 'Worked: ${_fmtDuration(worked)}', + 'Worked: ${formatDurationHm(worked)}', style: tt.titleSmall ?.copyWith(fontWeight: FontWeight.w600), ), @@ -195,6 +234,10 @@ class _DetailBody extends ConsumerWidget { ), ), ), + if (ledger.total > Duration.zero) ...[ + const SizedBox(height: 16), + TimeLedgerCard(ledger: ledger, profileById: profileById), + ], if (canAct && !task.isTerminal) ...[ const SizedBox(height: 16), _ActionBar( @@ -203,15 +246,34 @@ class _DetailBody extends ConsumerWidget { isPaused: isPaused, ), ], - const SizedBox(height: 24), - const AppSectionHeader('Activity'), - if (logs.isEmpty) - Text('No activity yet.', - style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant)) - else - ...logs.map((l) => _ActivityRow(log: l, profileById: profileById)), - const SizedBox(height: 32), - ], + const SizedBox(height: 16), + const TabBar( + tabs: [ + Tab(text: 'Work Log'), + Tab(text: 'Comments'), + Tab(text: 'Activity'), + ], + ), + Expanded( + child: TabBarView( + children: [ + SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 16), + child: WorkLogSection(task: task, isAssignee: isAssignee), + ), + SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 16), + child: CommentSection(task: task), + ), + SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 16), + child: ActivityTimeline(logs: logs, profileById: profileById), + ), + ], + ), + ), + ], + ), ), ); } @@ -277,77 +339,6 @@ class _ActionBar extends ConsumerWidget { } } -class _ActivityRow extends StatelessWidget { - const _ActivityRow({required this.log, required this.profileById}); - - final ProgrammerTaskActivityLog log; - final Map profileById; - - @override - Widget build(BuildContext context) { - final tt = Theme.of(context).textTheme; - final cs = Theme.of(context).colorScheme; - final actor = log.actorId == null ? null : profileById[log.actorId]; - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(_iconFor(log.actionType), size: 16, color: cs.onSurfaceVariant), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${actor?.fullName ?? 'Someone'} · ${_labelFor(log.actionType)}', - style: tt.bodyMedium, - ), - Text( - AppTime.relative(log.createdAt), - style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), - ), - ], - ), - ), - ], - ), - ); - } -} - -String _fmtDuration(Duration d) { - final h = d.inHours; - final m = d.inMinutes.remainder(60); - if (h > 0) return '${h}h ${m}m'; - return '${m}m'; -} - -String _labelFor(String action) => switch (action) { - 'created' => 'created the task', - 'started' => 'started work', - 'paused' => 'paused', - 'resumed' => 'resumed', - 'completed' => 'completed the task', - 'cancelled' => 'cancelled the task', - 'assigned' => 'assigned the task', - 'reassigned' => 'reassigned the task', - 'updated' => 'updated details', - _ => action, -}; - -IconData _iconFor(String action) => switch (action) { - 'created' => Icons.add_task, - 'started' => Icons.play_arrow_rounded, - 'paused' => Icons.pause_rounded, - 'resumed' => Icons.play_arrow_rounded, - 'completed' => Icons.check_circle_outline, - 'cancelled' => Icons.cancel_outlined, - 'assigned' || 'reassigned' => Icons.person_add_alt_1_outlined, - 'updated' => Icons.edit_outlined, - _ => Icons.circle_outlined, -}; Future _complete( BuildContext context, diff --git a/lib/screens/programmer_tasks/programmer_task_edit.dart b/lib/screens/programmer_tasks/programmer_task_edit.dart new file mode 100644 index 00000000..3c2c7f91 --- /dev/null +++ b/lib/screens/programmer_tasks/programmer_task_edit.dart @@ -0,0 +1,89 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../models/programmer_task.model.dart'; +import '../../providers/programmer_tasks_provider.dart'; +import '../../widgets/quill_field.dart'; + +/// Edit a task's Title (plain) and Description (Quill). Saves via the existing +/// [ProgrammerTasksController.updateTask]. +Future showEditProgrammerTaskDialog( + BuildContext context, + WidgetRef ref, + ProgrammerTask task, +) async { + final titleCtrl = TextEditingController(text: task.title); + final descQuill = quillControllerFromDelta(task.description); + + final ok = await showDialog( + context: context, + builder: (context) { + final dialogWidth = + math.min(480.0, MediaQuery.of(context).size.width - 48); + return AlertDialog( + semanticLabel: 'Edit task', + title: const Text('Edit Task'), + content: SizedBox( + width: dialogWidth, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: titleCtrl, + autofocus: true, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration(labelText: 'Title'), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: Text('Description', + style: Theme.of(context).textTheme.bodySmall), + ), + const SizedBox(height: 4), + QuillFieldEditor( + controller: descQuill, + placeholder: 'Describe the task…', + height: 180, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Save'), + ), + ], + ); + }, + ); + + final title = titleCtrl.text.trim(); + titleCtrl.dispose(); + final description = + quillPlainText(descQuill).isEmpty ? '' : quillToDeltaJson(descQuill); + descQuill.dispose(); + if (ok != true || title.isEmpty || !context.mounted) return; + + final messenger = ScaffoldMessenger.of(context); + try { + await ref.read(programmerTasksControllerProvider).updateTask( + taskId: task.id, + title: title, + description: description, + ); + messenger.showSnackBar(const SnackBar(content: Text('Task updated'))); + } catch (e) { + messenger.showSnackBar(SnackBar(content: Text('Could not update: $e'))); + } +} diff --git a/lib/screens/programmer_tasks/programmer_task_prompts.dart b/lib/screens/programmer_tasks/programmer_task_prompts.dart new file mode 100644 index 00000000..779b86d1 --- /dev/null +++ b/lib/screens/programmer_tasks/programmer_task_prompts.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../models/programmer_task.model.dart'; +import '../../providers/profile_provider.dart'; +import '../../providers/programmer_tasks_provider.dart'; + +/// After the ASSIGNEE saves a work log or comment on their own actively-running +/// task, ask whether to pause the timer. No-op for anyone else, or when the task +/// isn't the caller's currently-running one. +Future maybePromptPauseTimer( + BuildContext context, + WidgetRef ref, + ProgrammerTask task, +) async { + final userId = ref.read(currentUserIdProvider); + if (task.assigneeId != userId) return; + final controller = ref.read(programmerTasksControllerProvider); + final messenger = ScaffoldMessenger.of(context); + // Fresh query — the running-task provider can lag right after a start/switch. + final running = await controller.findRunningTaskForCurrentUser(); + if (running?.id != task.id) return; + if (!context.mounted) return; + + final pause = await showDialog( + context: context, + builder: (context) => AlertDialog( + semanticLabel: 'Pause the timer', + title: const Text('Pause the timer?'), + content: Text('You logged on "${task.title}". Pause its timer now?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Keep running'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Pause'), + ), + ], + ), + ); + if (pause == true) { + await controller.pauseTask(taskId: task.id); + messenger.showSnackBar(const SnackBar(content: Text('Timer paused'))); + } +} + +/// After a HELPER logs Time Consumed on a task they don't own, ask whether to +/// deduct that time from their own currently-running task. No-op if they have no +/// running (unpaused) task. [helpedTaskId] is recorded for audit. +Future maybePromptDeductHelperTime( + BuildContext context, + WidgetRef ref, + int minutes, + String helpedTaskId, +) async { + if (minutes <= 0) return; + final controller = ref.read(programmerTasksControllerProvider); + final running = await controller.findRunningTaskForCurrentUser(); + if (running == null) return; + if (!context.mounted) return; + + final messenger = ScaffoldMessenger.of(context); + final deduct = await showDialog( + context: context, + builder: (context) => AlertDialog( + semanticLabel: 'Deduct helped time', + title: const Text('Deduct this time?'), + content: Text( + 'You logged $minutes min helping. Deduct it from your active task ' + '"${running.title}" so your time stays accurate?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Skip'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Deduct'), + ), + ], + ), + ); + if (deduct == true) { + await controller.addAdjustment( + taskId: running.id, + seconds: minutes * 60, + reason: 'helped', + sourceTaskId: helpedTaskId, + ); + messenger.showSnackBar( + SnackBar(content: Text('Deducted $minutes min from "${running.title}"')), + ); + } +} diff --git a/lib/screens/programmer_tasks/programmer_tasks_list_screen.dart b/lib/screens/programmer_tasks/programmer_tasks_list_screen.dart index 59eef322..d13243e3 100644 --- a/lib/screens/programmer_tasks/programmer_tasks_list_screen.dart +++ b/lib/screens/programmer_tasks/programmer_tasks_list_screen.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import '../../models/profile.model.dart'; import '../../models/programmer_task.model.dart'; import '../../providers/profile_provider.dart'; +import '../../providers/programmer_projects_provider.dart'; import '../../providers/programmer_tasks_provider.dart'; import '../../theme/app_status_colors.dart'; import '../../theme/m3_motion.dart'; @@ -17,6 +18,7 @@ import '../../widgets/responsive_body.dart'; import '../../widgets/status_pill.dart'; import '../../widgets/tech_chip.dart'; import 'programmer_task_actions.dart'; +import 'programmer_task_create.dart'; import 'programmer_task_status_kind.dart'; class ProgrammerTasksListScreen extends ConsumerStatefulWidget { @@ -128,7 +130,7 @@ class _ProgrammerTasksListScreenState bottom: 16, child: M3ExpandedFab( heroTag: 'create_programmer_task', - onPressed: () => _showCreateDialog(context), + onPressed: () => showCreateProgrammerTaskDialog(context, ref), icon: const Icon(Icons.add), label: const Text('New Task'), ), @@ -138,125 +140,6 @@ class _ProgrammerTasksListScreenState ); } - Future _showCreateDialog(BuildContext context) async { - final userId = ref.read(currentUserIdProvider); - final profiles = ref.read(profilesProvider).valueOrNull ?? const []; - final eligible = profiles - .where((p) => p.role == 'programmer' || p.role == 'admin') - .toList() - ..sort((a, b) => a.fullName.compareTo(b.fullName)); - - final titleCtrl = TextEditingController(); - final descCtrl = TextEditingController(); - var category = ProgrammerTaskCategory.softwareDevelopment; - var assigneeId = userId; - - final created = await showDialog( - context: context, - builder: (context) { - return StatefulBuilder( - builder: (context, setDialogState) { - return AlertDialog( - semanticLabel: 'New programmer task', - title: const Text('New Programmer Task'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: titleCtrl, - autofocus: true, - textCapitalization: TextCapitalization.sentences, - decoration: const InputDecoration( - labelText: 'Title', - hintText: 'What are you working on?', - ), - ), - const SizedBox(height: 12), - TextField( - controller: descCtrl, - minLines: 2, - maxLines: 4, - textCapitalization: TextCapitalization.sentences, - decoration: const InputDecoration( - labelText: 'Description (optional)', - ), - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: category, - decoration: const InputDecoration(labelText: 'Category'), - items: [ - for (final c in ProgrammerTaskCategory.all) - DropdownMenuItem(value: c, child: Text(c)), - ], - onChanged: (v) => - setDialogState(() => category = v ?? category), - ), - if (eligible.isNotEmpty) ...[ - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: assigneeId, - decoration: const InputDecoration(labelText: 'Assignee'), - items: [ - for (final p in eligible) - DropdownMenuItem( - value: p.id, - child: Text( - p.id == userId ? '${p.fullName} (me)' : p.fullName, - ), - ), - ], - onChanged: (v) => - setDialogState(() => assigneeId = v ?? assigneeId), - ), - ], - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Create'), - ), - ], - ); - }, - ); - }, - ); - - if (created != true) { - titleCtrl.dispose(); - descCtrl.dispose(); - return; - } - - final title = titleCtrl.text.trim(); - titleCtrl.dispose(); - final description = descCtrl.text.trim(); - descCtrl.dispose(); - if (title.isEmpty) return; - if (!context.mounted) return; - - final messenger = ScaffoldMessenger.of(context); - try { - final id = await ref.read(programmerTasksControllerProvider).createTask( - title: title, - category: category, - description: description.isEmpty ? null : description, - assigneeId: assigneeId, - ); - if (!context.mounted) return; - context.go('/programmer-tasks/$id'); - } catch (e) { - messenger.showSnackBar(SnackBar(content: Text('Could not create task: $e'))); - } - } } class _TaskCard extends ConsumerWidget { @@ -280,6 +163,10 @@ class _TaskCard extends ConsumerWidget { final canStart = isMine && task.status == ProgrammerTaskStatus.queued; final canResume = isPaused; + final project = task.projectId == null + ? null + : ref.watch(programmerProjectByIdProvider(task.projectId!)); + final pillKind = isPaused ? StatusKind.warning : programmerTaskStatusKind(task.status); final pillLabel = @@ -314,6 +201,9 @@ class _TaskCard extends ConsumerWidget { crossAxisAlignment: WrapCrossAlignment.center, children: [ TechChip(task.category, icon: Icons.category_outlined, dense: true), + if (project != null) + TechChip(project.name, + icon: Icons.folder_outlined, dense: true), if (assignee != null && !isMine) Row( mainAxisSize: MainAxisSize.min, diff --git a/lib/screens/programmer_tasks/widgets/activity_timeline.dart b/lib/screens/programmer_tasks/widgets/activity_timeline.dart new file mode 100644 index 00000000..410eeadc --- /dev/null +++ b/lib/screens/programmer_tasks/widgets/activity_timeline.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; + +import '../../../models/profile.model.dart'; +import '../../../models/programmer_task_activity_log.model.dart'; +import '../../../utils/app_time.dart'; +import '../../../widgets/app_section_header.dart'; + +/// The task's activity timeline (created/started/paused/adjusted/…), newest first. +class ActivityTimeline extends StatelessWidget { + const ActivityTimeline({ + super.key, + required this.logs, + required this.profileById, + }); + + final List logs; + final Map profileById; + + @override + Widget build(BuildContext context) { + final tt = Theme.of(context).textTheme; + final cs = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const AppSectionHeader('Activity'), + if (logs.isEmpty) + Text('No activity yet.', + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant)) + else + for (final log in logs) + _ActivityRow(log: log, profileById: profileById), + ], + ); + } +} + +class _ActivityRow extends StatelessWidget { + const _ActivityRow({required this.log, required this.profileById}); + + final ProgrammerTaskActivityLog log; + final Map profileById; + + @override + Widget build(BuildContext context) { + final tt = Theme.of(context).textTheme; + final cs = Theme.of(context).colorScheme; + final actor = log.actorId == null ? null : profileById[log.actorId]; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(_iconFor(log.actionType), size: 16, color: cs.onSurfaceVariant), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${actor?.fullName ?? 'Someone'} · ${_labelFor(log.actionType)}', + style: tt.bodyMedium, + ), + Text( + AppTime.relative(log.createdAt), + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + ], + ), + ), + ], + ), + ); + } +} + +String _labelFor(String action) => switch (action) { + 'created' => 'created the task', + 'started' => 'started work', + 'paused' => 'paused', + 'resumed' => 'resumed', + 'completed' => 'completed the task', + 'cancelled' => 'cancelled the task', + 'assigned' => 'assigned the task', + 'reassigned' => 'reassigned the task', + 'adjusted' || 'adjustment' => 'adjusted time', + 'updated' => 'updated details', + _ => action, +}; + +IconData _iconFor(String action) => switch (action) { + 'created' => Icons.add_task, + 'started' => Icons.play_arrow_rounded, + 'paused' => Icons.pause_rounded, + 'resumed' => Icons.play_arrow_rounded, + 'completed' => Icons.check_circle_outline, + 'cancelled' => Icons.cancel_outlined, + 'assigned' || 'reassigned' => Icons.person_add_alt_1_outlined, + 'adjustment' => Icons.timelapse_outlined, + 'updated' => Icons.edit_outlined, + _ => Icons.circle_outlined, +}; diff --git a/lib/screens/programmer_tasks/widgets/comment_section.dart b/lib/screens/programmer_tasks/widgets/comment_section.dart new file mode 100644 index 00000000..365ebff2 --- /dev/null +++ b/lib/screens/programmer_tasks/widgets/comment_section.dart @@ -0,0 +1,280 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../models/profile.model.dart'; +import '../../../models/programmer_task.model.dart'; +import '../../../models/programmer_task_comment.model.dart'; +import '../../../providers/profile_provider.dart'; +import '../../../providers/programmer_task_comments_provider.dart'; +import '../../../utils/app_time.dart'; +import '../../../widgets/app_section_header.dart'; +import '../../../widgets/m3_card.dart'; +import '../../../widgets/quill_field.dart'; +import '../programmer_task_prompts.dart'; + +const _imageExts = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'}; + +bool _isImage(String name) => + _imageExts.contains(name.split('.').last.toLowerCase()); + +/// Comment thread + composer. Any user who can see the task may comment and +/// attach files. The assignee is prompted to pause after commenting. +class CommentSection extends ConsumerStatefulWidget { + const CommentSection({super.key, required this.task}); + + final ProgrammerTask task; + + @override + ConsumerState createState() => _CommentSectionState(); +} + +class _CommentSectionState extends ConsumerState { + final _bodyQuill = quillControllerFromDelta(null); + final List _pending = []; + bool _sending = false; + + @override + void dispose() { + _bodyQuill.dispose(); + super.dispose(); + } + + Future _pickFiles() async { + final messenger = ScaffoldMessenger.of(context); + final result = await FilePicker.platform.pickFiles( + type: FileType.any, + withData: true, + allowMultiple: true, + ); + if (result == null) return; + for (final f in result.files) { + if (f.bytes == null) continue; + if (f.bytes!.length > 25 * 1024 * 1024) { + messenger.showSnackBar( + SnackBar(content: Text('${f.name} exceeds 25MB — skipped')), + ); + continue; + } + _pending.add((name: f.name, bytes: f.bytes!)); + } + if (mounted) setState(() {}); + } + + Future _send() async { + final hasText = quillPlainText(_bodyQuill).isNotEmpty; + if ((!hasText && _pending.isEmpty) || _sending) return; + setState(() => _sending = true); + final messenger = ScaffoldMessenger.of(context); + try { + await ref.read(programmerTaskCommentsControllerProvider).addComment( + taskId: widget.task.id, + body: hasText ? quillToDeltaJson(_bodyQuill) : '', + files: List.of(_pending), + ); + _bodyQuill.clear(); + _pending.clear(); + if (!mounted) return; + await maybePromptPauseTimer(context, ref, widget.task); + } catch (e) { + messenger.showSnackBar(SnackBar(content: Text('Could not send: $e'))); + } finally { + if (mounted) setState(() => _sending = false); + } + } + + @override + Widget build(BuildContext context) { + final tt = Theme.of(context).textTheme; + final cs = Theme.of(context).colorScheme; + final commentsAsync = + ref.watch(programmerTaskCommentsProvider(widget.task.id)); + final profiles = ref.watch(profilesProvider).valueOrNull ?? const []; + final profileById = {for (final p in profiles) p.id: p}; + final comments = commentsAsync.valueOrNull ?? const []; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const AppSectionHeader('Comments'), + // Composer + QuillFieldEditor( + controller: _bodyQuill, + placeholder: 'Add a comment…', + height: 110, + ), + if (_pending.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (var i = 0; i < _pending.length; i++) + InputChip( + label: Text(_pending[i].name, + overflow: TextOverflow.ellipsis), + onDeleted: () => setState(() => _pending.removeAt(i)), + ), + ], + ), + ], + const SizedBox(height: 8), + Row( + children: [ + IconButton( + tooltip: 'Attach files', + onPressed: _sending ? null : _pickFiles, + icon: const Icon(Icons.attach_file), + ), + const Spacer(), + FilledButton.tonalIcon( + onPressed: _sending ? null : _send, + icon: _sending + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.send, size: 18), + label: const Text('Send'), + ), + ], + ), + const SizedBox(height: 12), + if (comments.isEmpty) + Text('No comments yet.', + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant)) + else + for (final c in comments) + _CommentTile(comment: c, author: profileById[c.authorId]), + ], + ); + } +} + +class _CommentTile extends ConsumerWidget { + const _CommentTile({required this.comment, required this.author}); + + final ProgrammerTaskComment comment; + final Profile? author; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tt = Theme.of(context).textTheme; + final cs = Theme.of(context).colorScheme; + final controller = ref.read(programmerTaskCommentsControllerProvider); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: M3Card.outlined( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(author?.fullName ?? 'Someone', + style: tt.bodyMedium + ?.copyWith(fontWeight: FontWeight.w600)), + ), + Text(AppTime.relative(comment.createdAt), + style: + tt.bodySmall?.copyWith(color: cs.onSurfaceVariant)), + ], + ), + if (comment.body.trim().isNotEmpty) ...[ + const SizedBox(height: 4), + QuillReadOnly(comment.body, style: tt.bodyMedium), + ], + if (comment.attachments.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final a in comment.attachments) + _AttachmentChip( + attachment: a, + url: controller.attachmentUrl(a.path), + ), + ], + ), + ], + ], + ), + ), + ), + ); + } +} + +class _AttachmentChip extends StatelessWidget { + const _AttachmentChip({required this.attachment, required this.url}); + + final ProgrammerCommentAttachment attachment; + final String url; + + @override + Widget build(BuildContext context) { + if (_isImage(attachment.name)) { + return InkWell( + onTap: () => _openImage(context), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + url, + width: 84, + height: 84, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _fileChip(context), + ), + ), + ); + } + return _fileChip(context); + } + + Widget _fileChip(BuildContext context) => ActionChip( + avatar: const Icon(Icons.insert_drive_file_outlined, size: 16), + label: Text(attachment.name, overflow: TextOverflow.ellipsis), + onPressed: () => _showUrl(context), + ); + + void _openImage(BuildContext context) { + showDialog( + context: context, + builder: (context) => Dialog( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + InteractiveViewer(child: Image.network(url)), + Padding( + padding: const EdgeInsets.all(8), + child: Text(attachment.name, + style: Theme.of(context).textTheme.bodySmall), + ), + ], + ), + ), + ); + } + + void _showUrl(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + semanticLabel: 'Attachment link', + title: Text(attachment.name), + content: SelectableText(url), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ); + } +} diff --git a/lib/screens/programmer_tasks/widgets/time_ledger_card.dart b/lib/screens/programmer_tasks/widgets/time_ledger_card.dart new file mode 100644 index 00000000..c84c4c5b --- /dev/null +++ b/lib/screens/programmer_tasks/widgets/time_ledger_card.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +import '../../../models/profile.model.dart'; +import '../../../utils/programmer_task_ledger.dart'; +import '../../../widgets/app_section_header.dart'; +import '../../../widgets/m3_card.dart'; + +/// Per-contributor time breakdown for a task: the assignee's running duration +/// plus each helper's logged minutes, and the total. +class TimeLedgerCard extends StatelessWidget { + const TimeLedgerCard({ + super.key, + required this.ledger, + required this.profileById, + }); + + final TaskTimeLedger ledger; + final Map profileById; + + @override + Widget build(BuildContext context) { + if (ledger.total == Duration.zero) return const SizedBox.shrink(); + final tt = Theme.of(context).textTheme; + final cs = Theme.of(context).colorScheme; + + return M3Card.outlined( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppSectionHeader( + 'Time ledger', + trailing: Text( + formatDurationHm(ledger.total), + style: tt.titleSmall?.copyWith(fontWeight: FontWeight.w700), + ), + ), + for (final entry in ledger.all) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Icon( + entry.isAssignee + ? Icons.play_circle_outline + : Icons.volunteer_activism_outlined, + size: 16, + color: cs.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + profileById[entry.userId]?.fullName ?? 'Unknown', + style: tt.bodyMedium, + overflow: TextOverflow.ellipsis, + ), + ), + if (entry.isAssignee) + Padding( + padding: const EdgeInsets.only(right: 8), + child: Text('assignee', + style: tt.bodySmall + ?.copyWith(color: cs.onSurfaceVariant)), + ), + Text( + formatDurationHm(entry.duration), + style: tt.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/programmer_tasks/widgets/work_log_section.dart b/lib/screens/programmer_tasks/widgets/work_log_section.dart new file mode 100644 index 00000000..03095048 --- /dev/null +++ b/lib/screens/programmer_tasks/widgets/work_log_section.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../models/profile.model.dart'; +import '../../../models/programmer_task.model.dart'; +import '../../../providers/profile_provider.dart'; +import '../../../providers/programmer_task_work_logs_provider.dart'; +import '../../../utils/app_time.dart'; +import '../../../utils/programmer_task_ledger.dart'; +import '../../../widgets/app_section_header.dart'; +import '../../../widgets/quill_field.dart'; +import '../programmer_task_prompts.dart'; + +/// Work-log list + composer. The assignee logs progress notes (no time field); +/// a non-assignee helper gets Hours + Minutes fields and is prompted to deduct +/// that time from their own running task. The assignee is prompted to pause +/// after logging. +class WorkLogSection extends ConsumerStatefulWidget { + const WorkLogSection({super.key, required this.task, required this.isAssignee}); + + final ProgrammerTask task; + final bool isAssignee; + + @override + ConsumerState createState() => _WorkLogSectionState(); +} + +class _WorkLogSectionState extends ConsumerState { + final _descQuill = quillControllerFromDelta(null); + final _hoursCtrl = TextEditingController(); + final _minutesCtrl = TextEditingController(); + bool _saving = false; + + @override + void dispose() { + _descQuill.dispose(); + _hoursCtrl.dispose(); + _minutesCtrl.dispose(); + super.dispose(); + } + + Future _submit() async { + if (_saving) return; + if (quillPlainText(_descQuill).isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Describe what you worked on')), + ); + return; + } + int? minutes; + if (!widget.isAssignee) { + final h = int.tryParse(_hoursCtrl.text.trim()) ?? 0; + final m = int.tryParse(_minutesCtrl.text.trim()) ?? 0; + minutes = h * 60 + m; + if (minutes <= 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Enter the time you spent helping')), + ); + return; + } + } + + setState(() => _saving = true); + final messenger = ScaffoldMessenger.of(context); + try { + await ref.read(programmerTaskWorkLogsControllerProvider).addWorkLog( + taskId: widget.task.id, + description: quillToDeltaJson(_descQuill), + minutes: minutes, + ); + _descQuill.clear(); + _hoursCtrl.clear(); + _minutesCtrl.clear(); + if (!mounted) return; + if (widget.isAssignee) { + await maybePromptPauseTimer(context, ref, widget.task); + } else if (minutes != null) { + await maybePromptDeductHelperTime(context, ref, minutes, widget.task.id); + } + } catch (e) { + messenger.showSnackBar(SnackBar(content: Text('Could not save: $e'))); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final tt = Theme.of(context).textTheme; + final cs = Theme.of(context).colorScheme; + final logsAsync = ref.watch(programmerTaskWorkLogsProvider(widget.task.id)); + final profiles = ref.watch(profilesProvider).valueOrNull ?? const []; + final profileById = {for (final p in profiles) p.id: p}; + final logs = logsAsync.valueOrNull ?? const []; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const AppSectionHeader('Work Log'), + QuillFieldEditor( + controller: _descQuill, + placeholder: widget.isAssignee + ? 'What did you work on?' + : 'What did you help with?', + height: 120, + ), + const SizedBox(height: 8), + Row( + children: [ + if (!widget.isAssignee) ...[ + SizedBox( + width: 78, + child: TextField( + controller: _hoursCtrl, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + labelText: 'Hours', isDense: true), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 88, + child: TextField( + controller: _minutesCtrl, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + labelText: 'Minutes', isDense: true), + ), + ), + ], + const Spacer(), + FilledButton.tonalIcon( + onPressed: _saving ? null : _submit, + icon: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.add, size: 18), + label: const Text('Log'), + ), + ], + ), + const SizedBox(height: 12), + if (logs.isEmpty) + Text('No work logged yet.', + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant)) + else + for (final log in logs) + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + profileById[log.authorId]?.fullName ?? 'Someone', + style: tt.bodyMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + if (log.minutes != null) + Padding( + padding: const EdgeInsets.only(right: 8), + child: Text( + formatDurationHm(Duration(minutes: log.minutes!)), + style: tt.bodySmall?.copyWith( + color: cs.primary, + fontWeight: FontWeight.w700), + ), + ), + Text(AppTime.relative(log.createdAt), + style: tt.bodySmall + ?.copyWith(color: cs.onSurfaceVariant)), + ], + ), + const SizedBox(height: 2), + QuillReadOnly(log.description, style: tt.bodyMedium), + ], + ), + ), + ], + ); + } +} diff --git a/lib/utils/programmer_task_ledger.dart b/lib/utils/programmer_task_ledger.dart new file mode 100644 index 00000000..9a616778 --- /dev/null +++ b/lib/utils/programmer_task_ledger.dart @@ -0,0 +1,78 @@ +import '../models/programmer_task_work_log.model.dart'; + +/// Formats a worked duration compactly, e.g. `2h 15m` or `45m`. +String formatDurationHm(Duration d) { + final h = d.inHours; + final m = d.inMinutes.remainder(60); + if (h > 0) return '${h}h ${m}m'; + return '${m}m'; +} + +/// One contributor's share of a task's time. +class LedgerEntry { + final String userId; + final Duration duration; + final bool isAssignee; + + const LedgerEntry({ + required this.userId, + required this.duration, + required this.isAssignee, + }); +} + +/// Per-contributor time breakdown for a task: the assignee's effective running +/// duration plus each helper's summed logged minutes. +class TaskTimeLedger { + final LedgerEntry? assignee; + final List helpers; + + const TaskTimeLedger({required this.assignee, required this.helpers}); + + Duration get total => + (assignee?.duration ?? Duration.zero) + + helpers.fold(Duration.zero, (a, e) => a + e.duration); + + List get all => [?assignee, ...helpers]; +} + +/// Builds the per-contributor ledger for a task. +/// +/// [assigneeDuration] is the assignee's effective running duration (already +/// computed from the activity log, including any adjustments). Helper entries +/// sum the `minutes` of work logs authored by someone other than the assignee; +/// assignee work logs (minutes == null progress notes) are excluded because the +/// assignee's time comes from the running clock. +TaskTimeLedger computeTaskLedger({ + required String? assigneeId, + required Duration assigneeDuration, + required List workLogs, +}) { + final byHelper = {}; + for (final log in workLogs) { + final minutes = log.minutes; + if (minutes == null || minutes <= 0) continue; + if (log.authorId == assigneeId) continue; + byHelper[log.authorId] = (byHelper[log.authorId] ?? 0) + minutes; + } + + final helpers = byHelper.entries + .map((e) => LedgerEntry( + userId: e.key, + duration: Duration(minutes: e.value), + isAssignee: false, + )) + .toList() + ..sort((a, b) => b.duration.compareTo(a.duration)); + + return TaskTimeLedger( + assignee: assigneeId == null + ? null + : LedgerEntry( + userId: assigneeId, + duration: assigneeDuration, + isAssignee: true, + ), + helpers: helpers, + ); +} diff --git a/lib/utils/task_duration.dart b/lib/utils/task_duration.dart index 1a2a6840..a6295990 100644 --- a/lib/utils/task_duration.dart +++ b/lib/utils/task_duration.dart @@ -29,14 +29,17 @@ DateTime? resolveExecutionStart( } /// Computes the effective worked duration, subtracting all paused intervals -/// between the resolved start and [endAt]. +/// between the resolved start and [endAt], then subtracting [adjustment] +/// (e.g. time reassigned to another task when the user helped elsewhere). /// /// [events] must be in DESCENDING order (newest first) — the same order -/// returned by activity-log queries with `ascending: false`. +/// returned by activity-log queries with `ascending: false`. The result never +/// goes below zero. Duration computeEffectiveDuration({ required DateTime? fallbackStartedAt, required DateTime endAt, required List events, + Duration adjustment = Duration.zero, }) { final start = resolveExecutionStart(events, fallbackStartedAt); if (start == null || !endAt.isAfter(start)) return Duration.zero; @@ -67,7 +70,7 @@ Duration computeEffectiveDuration({ pausedTotal += endAt.difference(pausedSince); } - final total = endAt.difference(start) - pausedTotal; + final total = endAt.difference(start) - pausedTotal - adjustment; return total.isNegative ? Duration.zero : total; } diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 0faf5174..b2d7d4aa 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -460,13 +460,20 @@ List _buildSections(String role) { icon: Icons.task_outlined, selectedIcon: Icons.task, ), - if (role == 'admin' || role == 'programmer') + if (role == 'admin' || role == 'programmer') ...[ NavItem( label: 'Programmer Tasks', route: '/programmer-tasks', icon: Icons.developer_mode_outlined, selectedIcon: Icons.developer_mode, ), + NavItem( + label: 'Projects', + route: '/programmer-projects', + icon: Icons.folder_outlined, + selectedIcon: Icons.folder, + ), + ], NavItem( label: 'IT Service Requests', route: '/it-service-requests', @@ -723,6 +730,7 @@ String _routeToTitle(String location) { '/tickets' => 'Tickets', '/tasks' => 'Tasks', '/programmer-tasks' => 'Programmer Tasks', + '/programmer-projects' => 'Projects', '/it-service-requests' => 'IT Service Requests', '/network-map' => 'Network Map', '/notifications' => 'Notifications', diff --git a/lib/widgets/quill_field.dart b/lib/widgets/quill_field.dart new file mode 100644 index 00000000..65597b65 --- /dev/null +++ b/lib/widgets/quill_field.dart @@ -0,0 +1,144 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart' as quill; + +/// Parses a stored Delta-JSON string into a [quill.QuillController], or returns +/// an empty one. Callers own the controller and must dispose it. +quill.QuillController quillControllerFromDelta(String? deltaJson) { + if (deltaJson != null && deltaJson.isNotEmpty) { + try { + final decoded = jsonDecode(deltaJson); + if (decoded is List) { + return quill.QuillController( + document: quill.Document.fromJson(decoded), + selection: const TextSelection.collapsed(offset: 0), + ); + } + } catch (_) {} + } + return quill.QuillController.basic(); +} + +/// Serializes a controller's document to a Delta-JSON string for storage. +String quillToDeltaJson(quill.QuillController controller) => + jsonEncode(controller.document.toDelta().toJson()); + +/// The controller's plain text (trimmed) — for empty checks and previews. +String quillPlainText(quill.QuillController controller) => + controller.document.toPlainText().trim(); + +/// Bordered rich-text editor with a minimal toolbar (bold/italic/underline + +/// bullet/numbered lists). The caller owns [controller] and disposes it. +class QuillFieldEditor extends StatelessWidget { + const QuillFieldEditor({ + super.key, + required this.controller, + this.placeholder, + this.height = 140, + }); + + final quill.QuillController controller; + final String? placeholder; + final double height; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Container( + height: height, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + border: Border.all(color: cs.outlineVariant), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + SizedBox( + height: 40, + child: quill.QuillSimpleToolbar( + controller: controller, + config: const quill.QuillSimpleToolbarConfig( + multiRowsDisplay: false, + showAlignmentButtons: false, + showBackgroundColorButton: false, + showCenterAlignment: false, + showColorButton: false, + showDividers: false, + showFontFamily: false, + showFontSize: false, + showHeaderStyle: false, + showIndent: false, + showInlineCode: false, + showLeftAlignment: false, + showLink: false, + showQuote: false, + showRightAlignment: false, + showSearchButton: false, + showCodeBlock: false, + showDirection: false, + showJustifyAlignment: false, + showListCheck: false, + showSubscript: false, + showSuperscript: false, + showStrikeThrough: false, + showSmallButton: false, + showClearFormat: false, + showRedo: false, + showUndo: false, + ), + ), + ), + const Divider(height: 1), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: quill.QuillEditor.basic( + controller: controller, + config: quill.QuillEditorConfig( + placeholder: placeholder, + expands: true, + ), + ), + ), + ), + ], + ), + ); + } +} + +/// Renders a stored value read-only. Interprets a Delta-JSON string as rich +/// text; falls back to a plain [Text] for legacy plain-string values. +class QuillReadOnly extends StatelessWidget { + const QuillReadOnly(this.deltaOrText, {super.key, this.style}); + + final String deltaOrText; + final TextStyle? style; + + @override + Widget build(BuildContext context) { + final trimmed = deltaOrText.trim(); + if (trimmed.startsWith('[')) { + try { + final decoded = jsonDecode(trimmed); + if (decoded is List) { + final controller = quill.QuillController( + document: quill.Document.fromJson(decoded), + selection: const TextSelection.collapsed(offset: 0), + ); + return IgnorePointer( + child: quill.QuillEditor.basic( + controller: controller, + config: const quill.QuillEditorConfig( + showCursor: false, + autoFocus: false, + ), + ), + ); + } + } catch (_) {} + } + return Text(deltaOrText, style: style); + } +} diff --git a/supabase/migrations/20260927130000_harden_programmer_tasks_rls.sql b/supabase/migrations/20260927130000_harden_programmer_tasks_rls.sql new file mode 100644 index 00000000..8bde5cd1 --- /dev/null +++ b/supabase/migrations/20260927130000_harden_programmer_tasks_rls.sql @@ -0,0 +1,51 @@ +-- Hardens the programmer_tasks RLS shipped in 20260927120000, per the commit +-- security review (two MEDIUM findings). The base migration is already applied +-- remotely, so this is a corrective follow-up. + +-- 1) audit-log-forgery: the activity-log INSERT policy did not bind actor_id to +-- the caller, so an authorized user could attribute a log entry to someone +-- else. Require actor_id to be the caller (or null for system rows). +drop policy if exists "programmer_task_activity_logs_insert" on programmer_task_activity_logs; +create policy "programmer_task_activity_logs_insert" on programmer_task_activity_logs + for insert to authenticated + with check ( + (actor_id = auth.uid() or actor_id is null) + and exists ( + select 1 from programmer_tasks t + where t.id = task_id + and ( + t.assignee_id = auth.uid() + or t.creator_id = auth.uid() + or exists ( + select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer') + ) + ) + ) + ); + +-- 2) column-immutability: the UPDATE policy allowed an assignee to mutate any +-- column, including creator_id/created_at (the DELETE policy keys off +-- creator_id). Make those columns immutable via a trigger. +create or replace function programmer_tasks_guard_immutable() +returns trigger +language plpgsql +set search_path = '' +as $$ +begin + if new.creator_id is distinct from old.creator_id then + raise exception 'creator_id is immutable'; + end if; + if new.created_at is distinct from old.created_at then + raise exception 'created_at is immutable'; + end if; + return new; +end; +$$; + +drop trigger if exists trg_programmer_tasks_guard_immutable on programmer_tasks; +create trigger trg_programmer_tasks_guard_immutable + before update on programmer_tasks + for each row execute function programmer_tasks_guard_immutable(); + +-- Note: activity-logs UPDATE/DELETE remain denied (no such policies exist), +-- keeping the audit trail append-only. New log/ledger tables follow the same shape. diff --git a/supabase/migrations/20260927140000_add_programmer_projects_worklogs_comments.sql b/supabase/migrations/20260927140000_add_programmer_projects_worklogs_comments.sql new file mode 100644 index 00000000..e52ecffd --- /dev/null +++ b/supabase/migrations/20260927140000_add_programmer_projects_worklogs_comments.sql @@ -0,0 +1,151 @@ +-- Programmer Tasks PM extension: projects, work logs (per-contributor time +-- ledger), and comments with attachments. All tables start hardened (author +-- bound to auth.uid(); logs/work-logs append-only; immutable creator/created_at). + +-- Visibility helper: true iff the caller can see the task. SECURITY INVOKER so +-- it runs under the caller's own programmer_tasks RLS (no SECURITY DEFINER +-- exposure); reused by work-log and comment policies. +create or replace function programmer_task_visible(t_id uuid) +returns boolean +language sql +stable +security invoker +set search_path = public +as $$ + select exists (select 1 from programmer_tasks where id = t_id); +$$; + +-- --------------------------------------------------------------------------- +-- Projects +-- --------------------------------------------------------------------------- +create table if not exists programmer_projects ( + id uuid primary key default gen_random_uuid(), + name text not null, + description text, + status text not null default 'active', -- active | archived + creator_id uuid references profiles(id), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create index if not exists idx_programmer_projects_status on programmer_projects(status); + +-- Reuse the table-agnostic updated_at + immutability trigger functions from the +-- base/harden migrations. +drop trigger if exists trg_programmer_projects_updated_at on programmer_projects; +create trigger trg_programmer_projects_updated_at + before update on programmer_projects + for each row execute function set_programmer_tasks_updated_at(); + +drop trigger if exists trg_programmer_projects_guard_immutable on programmer_projects; +create trigger trg_programmer_projects_guard_immutable + before update on programmer_projects + for each row execute function programmer_tasks_guard_immutable(); + +alter table programmer_projects enable row level security; + +drop policy if exists "programmer_projects_select" on programmer_projects; +create policy "programmer_projects_select" on programmer_projects + for select to authenticated + using ( + creator_id = auth.uid() + or exists (select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer')) + ); + +drop policy if exists "programmer_projects_insert" on programmer_projects; +create policy "programmer_projects_insert" on programmer_projects + for insert to authenticated + with check ( + creator_id = auth.uid() + and exists (select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer')) + ); + +drop policy if exists "programmer_projects_update" on programmer_projects; +create policy "programmer_projects_update" on programmer_projects + for update to authenticated + using ( + creator_id = auth.uid() + or exists (select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer')) + ) + with check ( + creator_id = auth.uid() + or exists (select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer')) + ); + +drop policy if exists "programmer_projects_delete" on programmer_projects; +create policy "programmer_projects_delete" on programmer_projects + for delete to authenticated + using ( + creator_id = auth.uid() + or exists (select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer')) + ); + +-- Link tasks to a project (optional; unset on project delete). +alter table programmer_tasks add column if not exists project_id uuid + references programmer_projects(id) on delete set null; +create index if not exists idx_programmer_tasks_project_id on programmer_tasks(project_id); + +-- --------------------------------------------------------------------------- +-- Work logs (per-contributor time ledger) +-- --------------------------------------------------------------------------- +create table if not exists programmer_task_work_logs ( + id uuid primary key default gen_random_uuid(), + task_id uuid not null references programmer_tasks(id) on delete cascade, + author_id uuid not null references profiles(id), + description text not null, + minutes int check (minutes is null or minutes > 0), -- null = assignee progress note + created_at timestamptz not null default now() +); +create index if not exists idx_programmer_task_work_logs_task_id on programmer_task_work_logs(task_id); + +alter table programmer_task_work_logs enable row level security; + +-- Append-only: select + insert only (no update/delete policies). +drop policy if exists "programmer_task_work_logs_select" on programmer_task_work_logs; +create policy "programmer_task_work_logs_select" on programmer_task_work_logs + for select to authenticated + using (programmer_task_visible(task_id)); + +drop policy if exists "programmer_task_work_logs_insert" on programmer_task_work_logs; +create policy "programmer_task_work_logs_insert" on programmer_task_work_logs + for insert to authenticated + with check (author_id = auth.uid() and programmer_task_visible(task_id)); + +-- --------------------------------------------------------------------------- +-- Comments (+ attachments stored in the task_attachments bucket) +-- --------------------------------------------------------------------------- +create table if not exists programmer_task_comments ( + id uuid primary key default gen_random_uuid(), + task_id uuid not null references programmer_tasks(id) on delete cascade, + author_id uuid not null references profiles(id), + body text not null default '', + attachments jsonb not null default '[]'::jsonb, -- [{path, name}] + created_at timestamptz not null default now() +); +create index if not exists idx_programmer_task_comments_task_id on programmer_task_comments(task_id); + +alter table programmer_task_comments enable row level security; + +drop policy if exists "programmer_task_comments_select" on programmer_task_comments; +create policy "programmer_task_comments_select" on programmer_task_comments + for select to authenticated + using (programmer_task_visible(task_id)); + +drop policy if exists "programmer_task_comments_insert" on programmer_task_comments; +create policy "programmer_task_comments_insert" on programmer_task_comments + for insert to authenticated + with check (author_id = auth.uid() and programmer_task_visible(task_id)); + +drop policy if exists "programmer_task_comments_delete" on programmer_task_comments; +create policy "programmer_task_comments_delete" on programmer_task_comments + for delete to authenticated + using ( + author_id = auth.uid() + or exists (select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer')) + ); + +-- --------------------------------------------------------------------------- +-- Realtime +-- --------------------------------------------------------------------------- +alter publication supabase_realtime add table programmer_projects; +alter publication supabase_realtime add table programmer_task_work_logs; +alter publication supabase_realtime add table programmer_task_comments; diff --git a/test/programmer_task_ledger_test.dart b/test/programmer_task_ledger_test.dart new file mode 100644 index 00000000..08b75121 --- /dev/null +++ b/test/programmer_task_ledger_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tasq/models/programmer_task_work_log.model.dart'; +import 'package:tasq/utils/app_time.dart'; +import 'package:tasq/utils/programmer_task_ledger.dart'; +import 'package:tasq/utils/task_duration.dart'; + +ProgrammerTaskWorkLog _log(String author, int? minutes) => ProgrammerTaskWorkLog( + id: 'l-$author-$minutes', + taskId: 't1', + authorId: author, + description: 'work', + minutes: minutes, + createdAt: DateTime.utc(2026, 1, 1), + ); + +void main() { + setUpAll(AppTime.initialize); + + group('computeTaskLedger', () { + test('assignee only (no work logs)', () { + final ledger = computeTaskLedger( + assigneeId: 'a', + assigneeDuration: const Duration(hours: 1), + workLogs: const [], + ); + expect(ledger.assignee?.duration, const Duration(hours: 1)); + expect(ledger.helpers, isEmpty); + expect(ledger.total, const Duration(hours: 1)); + }); + + test('sums helper minutes per person; assignee notes excluded', () { + final ledger = computeTaskLedger( + assigneeId: 'a', + assigneeDuration: const Duration(minutes: 60), + workLogs: [ + _log('h1', 30), + _log('h1', 15), + _log('h2', 20), + _log('a', null), // assignee progress note — no minutes, excluded + _log('a', 99), // even if an assignee row had minutes, exclude it + ], + ); + expect(ledger.assignee?.duration, const Duration(minutes: 60)); + final byUser = {for (final e in ledger.helpers) e.userId: e.duration}; + expect(byUser['h1'], const Duration(minutes: 45)); + expect(byUser['h2'], const Duration(minutes: 20)); + expect(byUser.containsKey('a'), isFalse); + expect(ledger.total, const Duration(minutes: 60 + 45 + 20)); + }); + + test('helpers sorted by descending duration', () { + final ledger = computeTaskLedger( + assigneeId: 'a', + assigneeDuration: Duration.zero, + workLogs: [_log('h1', 10), _log('h2', 40)], + ); + expect(ledger.helpers.first.userId, 'h2'); + }); + + test('no assignee id → assignee entry is null', () { + final ledger = computeTaskLedger( + assigneeId: null, + assigneeDuration: const Duration(hours: 5), + workLogs: [_log('h1', 30)], + ); + expect(ledger.assignee, isNull); + expect(ledger.total, const Duration(minutes: 30)); + }); + }); + + group('computeEffectiveDuration adjustment', () { + final base = DateTime.utc(2026, 1, 1, 9); + test('subtracts the adjustment', () { + final worked = computeEffectiveDuration( + fallbackStartedAt: base, + endAt: base.add(const Duration(hours: 2)), + events: const [], + adjustment: const Duration(minutes: 30), + ); + expect(worked, const Duration(hours: 1, minutes: 30)); + }); + + test('clamps to zero when the adjustment exceeds worked time', () { + final worked = computeEffectiveDuration( + fallbackStartedAt: base, + endAt: base.add(const Duration(minutes: 20)), + events: const [], + adjustment: const Duration(hours: 1), + ); + expect(worked, Duration.zero); + }); + + test('default adjustment is zero (existing behavior unchanged)', () { + final worked = computeEffectiveDuration( + fallbackStartedAt: base, + endAt: base.add(const Duration(hours: 1)), + events: const [], + ); + expect(worked, const Duration(hours: 1)); + }); + }); +} diff --git a/test/programmer_tasks_controller_test.dart b/test/programmer_tasks_controller_test.dart index e912db27..05a84c2a 100644 --- a/test/programmer_tasks_controller_test.dart +++ b/test/programmer_tasks_controller_test.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tasq/models/programmer_task.model.dart'; +import 'package:tasq/providers/programmer_task_comments_provider.dart'; +import 'package:tasq/providers/programmer_task_work_logs_provider.dart'; import 'package:tasq/providers/programmer_tasks_provider.dart'; import 'package:tasq/screens/programmer_tasks/programmer_task_actions.dart'; import 'package:tasq/utils/app_time.dart'; @@ -31,6 +33,8 @@ class _FakeClient { final Map>> tables = { 'programmer_tasks': [], 'programmer_task_activity_logs': [], + 'programmer_task_work_logs': [], + 'programmer_task_comments': [], }; _FakeAuth get auth => _FakeAuth(userId); @@ -459,4 +463,79 @@ void main() { expect(_task(fake, 'B')['status'], ProgrammerTaskStatus.queued); }); }); + + group('projects, adjustments & collaboration', () { + test('createTask carries project_id', () async { + final fake = _FakeClient('u1'); + final c = ProgrammerTasksController(fake as dynamic); + final id = await c.createTask( + title: 't', + category: ProgrammerTaskCategory.others, + projectId: 'proj-1', + ); + expect(_task(fake, id)['project_id'], 'proj-1'); + }); + + test('setProject updates project_id and logs an update', () async { + final fake = _FakeClient('u1'); + final c = ProgrammerTasksController(fake as dynamic); + final id = await c.createTask( + title: 't', + category: ProgrammerTaskCategory.others, + ); + await c.setProject(taskId: id, projectId: 'proj-9'); + expect(_task(fake, id)['project_id'], 'proj-9'); + expect( + _logs(fake).any((l) => + l['action_type'] == 'updated' && + (l['meta']?['fields'] as List?)?.contains('project_id') == true), + isTrue, + ); + }); + + test('addAdjustment records a signed adjustment log', () async { + final fake = _FakeClient('u1'); + final c = ProgrammerTasksController(fake as dynamic); + final id = await c.createTask( + title: 't', + category: ProgrammerTaskCategory.others, + ); + await c.addAdjustment( + taskId: id, + seconds: 1800, + reason: 'helped', + sourceTaskId: 'other', + ); + final adj = _logs(fake).firstWhere((l) => l['action_type'] == 'adjustment'); + expect(adj['meta']['seconds'], 1800); + expect(adj['meta']['reason'], 'helped'); + expect(adj['meta']['source_task_id'], 'other'); + }); + + test('addWorkLog stores helper minutes and omits them for the assignee', + () async { + final fake = _FakeClient('u1'); + final wl = ProgrammerTaskWorkLogsController(fake as dynamic); + await wl.addWorkLog(taskId: 't1', description: 'helped', minutes: 30); + await wl.addWorkLog(taskId: 't1', description: 'progress'); // assignee note + + final rows = fake.tables['programmer_task_work_logs']!; + expect(rows.length, 2); + expect(rows[0]['author_id'], 'u1'); + expect(rows[0]['minutes'], 30); + expect(rows[1].containsKey('minutes'), isFalse); // null minutes omitted + }); + + test('addComment stores body + author with empty attachments', () async { + final fake = _FakeClient('u1'); + final cc = ProgrammerTaskCommentsController(fake as dynamic); + await cc.addComment(taskId: 't1', body: 'looks good'); + + final rows = fake.tables['programmer_task_comments']!; + expect(rows.length, 1); + expect(rows.first['author_id'], 'u1'); + expect(rows.first['body'], 'looks good'); + expect(rows.first['attachments'], isEmpty); + }); + }); }