From 627645056bb02f395527d1020656c8818114923e Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Sun, 27 Sep 2026 01:13:33 +0800 Subject: [PATCH] Add Programmer Tasks module (work-log + time tracking) Separate feature for the `programmer` role to record dev work by category, distinct from the IT help-desk `tasks` feature. Modeled on the IT Service Requests pattern (Brick offline-first model -> Riverpod stream/controller -> list/detail screens); seed of a future Project Management workflow. - programmer_tasks + programmer_task_activity_logs (category enum, RLS, realtime) via supabase migration - single assignee, self-logged or lead-assigned; assign/reassign logged - pause-on-switch: starting a task while another runs prompts to pause the running one, keeping one active task per user for accurate time attribution - worked-duration math shared with help-desk tasks via a generic core in task_duration.dart (existing callers unchanged) - list distinguishes running vs paused with a per-task Resume shortcut - accessible dialog labels; route /programmer-tasks + nav gated to admin/programmer - unit/widget tests: controller lifecycle, duration math, pause-switch dialog Co-Authored-By: claude-flow --- ...rogrammer_task_activity_log_adapter.g.dart | 197 ++++++ .../adapters/programmer_task_adapter.g.dart | 361 +++++++++++ lib/brick/brick.g.dart | 8 + lib/brick/db/20260926162926.migration.dart | 80 +++ lib/brick/db/schema.g.dart | 49 ++ lib/models/programmer_task.model.dart | 145 +++++ .../programmer_task_activity_log.model.dart | 77 +++ lib/providers/programmer_tasks_provider.dart | 592 ++++++++++++++++++ lib/routing/app_router.dart | 24 + .../programmer_task_actions.dart | 63 ++ .../programmer_task_detail_screen.dart | 475 ++++++++++++++ .../programmer_task_status_kind.dart | 13 + .../programmer_tasks_list_screen.dart | 354 +++++++++++ lib/utils/task_duration.dart | 73 ++- lib/widgets/app_shell.dart | 8 + .../20260927120000_add_programmer_tasks.sql | 168 +++++ test/programmer_task_duration_test.dart | 85 +++ test/programmer_tasks_controller_test.dart | 462 ++++++++++++++ 18 files changed, 3214 insertions(+), 20 deletions(-) create mode 100644 lib/brick/adapters/programmer_task_activity_log_adapter.g.dart create mode 100644 lib/brick/adapters/programmer_task_adapter.g.dart create mode 100644 lib/brick/db/20260926162926.migration.dart create mode 100644 lib/models/programmer_task.model.dart create mode 100644 lib/models/programmer_task_activity_log.model.dart create mode 100644 lib/providers/programmer_tasks_provider.dart create mode 100644 lib/screens/programmer_tasks/programmer_task_actions.dart create mode 100644 lib/screens/programmer_tasks/programmer_task_detail_screen.dart create mode 100644 lib/screens/programmer_tasks/programmer_task_status_kind.dart create mode 100644 lib/screens/programmer_tasks/programmer_tasks_list_screen.dart create mode 100644 supabase/migrations/20260927120000_add_programmer_tasks.sql create mode 100644 test/programmer_task_duration_test.dart create mode 100644 test/programmer_tasks_controller_test.dart diff --git a/lib/brick/adapters/programmer_task_activity_log_adapter.g.dart b/lib/brick/adapters/programmer_task_activity_log_adapter.g.dart new file mode 100644 index 00000000..e8b6ba03 --- /dev/null +++ b/lib/brick/adapters/programmer_task_activity_log_adapter.g.dart @@ -0,0 +1,197 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$ProgrammerTaskActivityLogFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return ProgrammerTaskActivityLog( + id: data['id'] as String, + taskId: data['task_id'] as String, + actorId: data['actor_id'] == null ? null : data['actor_id'] as String?, + actionType: data['action_type'] as String, + meta: data['meta'] == null ? null : data['meta'], + createdAt: DateTime.parse(data['created_at'] as String), + ); +} + +Future> _$ProgrammerTaskActivityLogToSupabase( + ProgrammerTaskActivityLog instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'task_id': instance.taskId, + 'actor_id': instance.actorId, + 'action_type': instance.actionType, + 'meta': instance.meta, + 'created_at': instance.createdAt.toIso8601String(), + }; +} + +Future _$ProgrammerTaskActivityLogFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return ProgrammerTaskActivityLog( + id: data['id'] as String, + taskId: data['task_id'] as String, + actorId: data['actor_id'] == null ? null : data['actor_id'] as String?, + actionType: data['action_type'] as String, + meta: data['meta'] == null ? null : jsonDecode(data['meta']), + createdAt: DateTime.parse(data['created_at'] as String), + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$ProgrammerTaskActivityLogToSqlite( + ProgrammerTaskActivityLog instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'task_id': instance.taskId, + 'actor_id': instance.actorId, + 'action_type': instance.actionType, + 'meta': instance.meta != null ? jsonEncode(instance.meta) : null, + 'created_at': instance.createdAt.toIso8601String(), + }; +} + +/// Construct a [ProgrammerTaskActivityLog] +class ProgrammerTaskActivityLogAdapter + extends OfflineFirstWithSupabaseAdapter { + ProgrammerTaskActivityLogAdapter(); + + @override + final supabaseTableName = 'programmer_task_activity_logs'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'taskId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'task_id', + ), + 'actorId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'actor_id', + ), + 'actionType': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'action_type', + ), + 'meta': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'meta', + ), + 'createdAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'created_at', + ), + }; + @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, + ), + 'taskId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'task_id', + iterable: false, + type: String, + ), + 'actorId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'actor_id', + iterable: false, + type: String, + ), + 'actionType': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'action_type', + iterable: false, + type: String, + ), + 'meta': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'meta', + iterable: false, + type: Map, + ), + 'createdAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'created_at', + iterable: false, + type: DateTime, + ), + }; + @override + Future primaryKeyByUniqueColumns( + ProgrammerTaskActivityLog instance, + DatabaseExecutor executor, + ) async => instance.primaryKey; + @override + final String tableName = 'ProgrammerTaskActivityLog'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ProgrammerTaskActivityLogFromSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSupabase( + ProgrammerTaskActivityLog input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ProgrammerTaskActivityLogToSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ProgrammerTaskActivityLogFromSqlite( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSqlite( + ProgrammerTaskActivityLog input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ProgrammerTaskActivityLogToSqlite( + 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 new file mode 100644 index 00000000..e8ae4b51 --- /dev/null +++ b/lib/brick/adapters/programmer_task_adapter.g.dart @@ -0,0 +1,361 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$ProgrammerTaskFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return ProgrammerTask( + id: data['id'] as String, + title: data['title'] as String, + description: data['description'] == null + ? null + : data['description'] as String?, + category: data['category'] as String, + status: data['status'] as String, + priority: data['priority'] as int, + assigneeId: data['assignee_id'] == null + ? null + : data['assignee_id'] as String?, + creatorId: data['creator_id'] == null + ? null + : data['creator_id'] as String?, + createdAt: DateTime.parse(data['created_at'] as String), + startedAt: data['started_at'] == null + ? null + : data['started_at'] == null + ? null + : DateTime.tryParse(data['started_at'] as String), + completedAt: data['completed_at'] == null + ? null + : data['completed_at'] == null + ? null + : DateTime.tryParse(data['completed_at'] as String), + cancelledAt: data['cancelled_at'] == null + ? null + : data['cancelled_at'] == null + ? null + : DateTime.tryParse(data['cancelled_at'] as String), + cancellationReason: data['cancellation_reason'] == null + ? null + : data['cancellation_reason'] as String?, + updatedAt: DateTime.parse(data['updated_at'] as String), + ); +} + +Future> _$ProgrammerTaskToSupabase( + ProgrammerTask instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'title': instance.title, + 'description': instance.description, + 'category': instance.category, + 'status': instance.status, + 'priority': instance.priority, + 'assignee_id': instance.assigneeId, + 'creator_id': instance.creatorId, + 'created_at': instance.createdAt.toIso8601String(), + 'started_at': instance.startedAt?.toIso8601String(), + 'completed_at': instance.completedAt?.toIso8601String(), + 'cancelled_at': instance.cancelledAt?.toIso8601String(), + 'cancellation_reason': instance.cancellationReason, + 'updated_at': instance.updatedAt.toIso8601String(), + 'is_terminal': instance.isTerminal, + }; +} + +Future _$ProgrammerTaskFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return ProgrammerTask( + id: data['id'] as String, + title: data['title'] as String, + description: data['description'] == null + ? null + : data['description'] as String?, + category: data['category'] as String, + status: data['status'] as String, + priority: data['priority'] as int, + assigneeId: data['assignee_id'] == null + ? null + : data['assignee_id'] as String?, + creatorId: data['creator_id'] == null + ? null + : data['creator_id'] as String?, + createdAt: DateTime.parse(data['created_at'] as String), + startedAt: data['started_at'] == null + ? null + : data['started_at'] == null + ? null + : DateTime.tryParse(data['started_at'] as String), + completedAt: data['completed_at'] == null + ? null + : data['completed_at'] == null + ? null + : DateTime.tryParse(data['completed_at'] as String), + cancelledAt: data['cancelled_at'] == null + ? null + : data['cancelled_at'] == null + ? null + : DateTime.tryParse(data['cancelled_at'] as String), + cancellationReason: data['cancellation_reason'] == null + ? null + : data['cancellation_reason'] as String?, + updatedAt: DateTime.parse(data['updated_at'] as String), + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$ProgrammerTaskToSqlite( + ProgrammerTask instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'title': instance.title, + 'description': instance.description, + 'category': instance.category, + 'status': instance.status, + 'priority': instance.priority, + 'assignee_id': instance.assigneeId, + 'creator_id': instance.creatorId, + 'created_at': instance.createdAt.toIso8601String(), + 'started_at': instance.startedAt?.toIso8601String(), + 'completed_at': instance.completedAt?.toIso8601String(), + 'cancelled_at': instance.cancelledAt?.toIso8601String(), + 'cancellation_reason': instance.cancellationReason, + 'updated_at': instance.updatedAt.toIso8601String(), + 'is_terminal': instance.isTerminal ? 1 : 0, + }; +} + +/// Construct a [ProgrammerTask] +class ProgrammerTaskAdapter + extends OfflineFirstWithSupabaseAdapter { + ProgrammerTaskAdapter(); + + @override + final supabaseTableName = 'programmer_tasks'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'title': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'title', + ), + 'description': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'description', + ), + 'category': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'category', + ), + 'status': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'status', + ), + 'priority': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'priority', + ), + 'assigneeId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'assignee_id', + ), + 'creatorId': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'creator_id', + ), + 'createdAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'created_at', + ), + 'startedAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'started_at', + ), + 'completedAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'completed_at', + ), + 'cancelledAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'cancelled_at', + ), + 'cancellationReason': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'cancellation_reason', + ), + 'updatedAt': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'updated_at', + ), + 'isTerminal': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'is_terminal', + ), + }; + @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, + ), + 'title': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'title', + iterable: false, + type: String, + ), + 'description': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'description', + iterable: false, + type: String, + ), + 'category': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'category', + iterable: false, + type: String, + ), + 'status': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'status', + iterable: false, + type: String, + ), + 'priority': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'priority', + iterable: false, + type: int, + ), + 'assigneeId': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'assignee_id', + 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, + ), + 'startedAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'started_at', + iterable: false, + type: DateTime, + ), + 'completedAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'completed_at', + iterable: false, + type: DateTime, + ), + 'cancelledAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'cancelled_at', + iterable: false, + type: DateTime, + ), + 'cancellationReason': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'cancellation_reason', + iterable: false, + type: String, + ), + 'updatedAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'updated_at', + iterable: false, + type: DateTime, + ), + 'isTerminal': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'is_terminal', + iterable: false, + type: bool, + ), + }; + @override + Future primaryKeyByUniqueColumns( + ProgrammerTask instance, + DatabaseExecutor executor, + ) async => instance.primaryKey; + @override + final String tableName = 'ProgrammerTask'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ProgrammerTaskFromSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSupabase( + ProgrammerTask input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ProgrammerTaskToSupabase( + input, + provider: provider, + repository: repository, + ); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ProgrammerTaskFromSqlite( + input, + provider: provider, + repository: repository, + ); + @override + Future> toSqlite( + ProgrammerTask input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => await _$ProgrammerTaskToSqlite( + input, + provider: provider, + repository: repository, + ); +} diff --git a/lib/brick/brick.g.dart b/lib/brick/brick.g.dart index 6196c0d6..ac6edca3 100644 --- a/lib/brick/brick.g.dart +++ b/lib/brick/brick.g.dart @@ -35,6 +35,8 @@ 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_task.model.dart'; +import '../models/programmer_task_activity_log.model.dart'; import '../models/service.model.dart'; import '../models/swap_request.model.dart'; import '../models/task.model.dart'; @@ -56,6 +58,8 @@ 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_task_adapter.g.dart'; +part 'adapters/programmer_task_activity_log_adapter.g.dart'; part 'adapters/service_adapter.g.dart'; part 'adapters/swap_request_adapter.g.dart'; part 'adapters/task_adapter.g.dart'; @@ -79,6 +83,8 @@ final Map> supabaseMappings = { Office: OfficeAdapter(), PassSlip: PassSlipAdapter(), Profile: ProfileAdapter(), + ProgrammerTask: ProgrammerTaskAdapter(), + ProgrammerTaskActivityLog: ProgrammerTaskActivityLogAdapter(), Service: ServiceAdapter(), SwapRequest: SwapRequestAdapter(), Task: TaskAdapter(), @@ -104,6 +110,8 @@ final Map> sqliteMappings = { Office: OfficeAdapter(), PassSlip: PassSlipAdapter(), Profile: ProfileAdapter(), + ProgrammerTask: ProgrammerTaskAdapter(), + ProgrammerTaskActivityLog: ProgrammerTaskActivityLogAdapter(), Service: ServiceAdapter(), SwapRequest: SwapRequestAdapter(), Task: TaskAdapter(), diff --git a/lib/brick/db/20260926162926.migration.dart b/lib/brick/db/20260926162926.migration.dart new file mode 100644 index 00000000..eb42c624 --- /dev/null +++ b/lib/brick/db/20260926162926.migration.dart @@ -0,0 +1,80 @@ +// 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_20260926162926_up = [ + InsertTable('ProgrammerTask'), + InsertTable('ProgrammerTaskActivityLog'), + InsertColumn('id', Column.varchar, onTable: 'ProgrammerTask'), + InsertColumn('title', Column.varchar, onTable: 'ProgrammerTask'), + InsertColumn('description', Column.varchar, onTable: 'ProgrammerTask'), + InsertColumn('category', Column.varchar, onTable: 'ProgrammerTask'), + InsertColumn('status', Column.varchar, onTable: 'ProgrammerTask'), + InsertColumn('priority', Column.integer, onTable: 'ProgrammerTask'), + InsertColumn('assignee_id', Column.varchar, onTable: 'ProgrammerTask'), + InsertColumn('creator_id', Column.varchar, onTable: 'ProgrammerTask'), + InsertColumn('created_at', Column.datetime, onTable: 'ProgrammerTask'), + InsertColumn('started_at', Column.datetime, onTable: 'ProgrammerTask'), + InsertColumn('completed_at', Column.datetime, onTable: 'ProgrammerTask'), + InsertColumn('cancelled_at', Column.datetime, onTable: 'ProgrammerTask'), + InsertColumn('cancellation_reason', Column.varchar, onTable: 'ProgrammerTask'), + InsertColumn('updated_at', Column.datetime, onTable: 'ProgrammerTask'), + InsertColumn('is_terminal', Column.boolean, onTable: 'ProgrammerTask'), + InsertColumn('id', Column.varchar, onTable: 'ProgrammerTaskActivityLog'), + InsertColumn('task_id', Column.varchar, onTable: 'ProgrammerTaskActivityLog'), + InsertColumn('actor_id', Column.varchar, onTable: 'ProgrammerTaskActivityLog'), + InsertColumn('action_type', Column.varchar, onTable: 'ProgrammerTaskActivityLog'), + InsertColumn('meta', Column.varchar, onTable: 'ProgrammerTaskActivityLog'), + InsertColumn('created_at', Column.datetime, onTable: 'ProgrammerTaskActivityLog') +]; + +const List _migration_20260926162926_down = [ + DropTable('ProgrammerTask'), + DropTable('ProgrammerTaskActivityLog'), + DropColumn('id', onTable: 'ProgrammerTask'), + DropColumn('title', onTable: 'ProgrammerTask'), + DropColumn('description', onTable: 'ProgrammerTask'), + DropColumn('category', onTable: 'ProgrammerTask'), + DropColumn('status', onTable: 'ProgrammerTask'), + DropColumn('priority', onTable: 'ProgrammerTask'), + DropColumn('assignee_id', onTable: 'ProgrammerTask'), + DropColumn('creator_id', onTable: 'ProgrammerTask'), + DropColumn('created_at', onTable: 'ProgrammerTask'), + DropColumn('started_at', onTable: 'ProgrammerTask'), + DropColumn('completed_at', onTable: 'ProgrammerTask'), + DropColumn('cancelled_at', onTable: 'ProgrammerTask'), + DropColumn('cancellation_reason', onTable: 'ProgrammerTask'), + DropColumn('updated_at', onTable: 'ProgrammerTask'), + DropColumn('is_terminal', onTable: 'ProgrammerTask'), + DropColumn('id', onTable: 'ProgrammerTaskActivityLog'), + DropColumn('task_id', onTable: 'ProgrammerTaskActivityLog'), + DropColumn('actor_id', onTable: 'ProgrammerTaskActivityLog'), + DropColumn('action_type', onTable: 'ProgrammerTaskActivityLog'), + DropColumn('meta', onTable: 'ProgrammerTaskActivityLog'), + DropColumn('created_at', onTable: 'ProgrammerTaskActivityLog') +]; + +// +// DO NOT EDIT BELOW THIS LINE +// + +@Migratable( + version: '20260926162926', + up: _migration_20260926162926_up, + down: _migration_20260926162926_down, +) +class Migration20260926162926 extends Migration { + const Migration20260926162926() + : super( + version: 20260926162926, + up: _migration_20260926162926_up, + down: _migration_20260926162926_down, + ); +} diff --git a/lib/brick/db/schema.g.dart b/lib/brick/db/schema.g.dart index b1b6c52e..42a3b217 100644 --- a/lib/brick/db/schema.g.dart +++ b/lib/brick/db/schema.g.dart @@ -1,11 +1,13 @@ // GENERATED CODE DO NOT EDIT // This file should be version controlled import 'package:brick_sqlite/db.dart'; +part '20260926162926.migration.dart'; part '20260411041808.migration.dart'; part '20260411042152.migration.dart'; /// All intelligently-generated migrations from all `@Migratable` classes on disk final migrations = { + const Migration20260926162926(), const Migration20260411041808(), const Migration20260411042152(), }; @@ -334,6 +336,53 @@ final schema = Schema( }, indices: {}, ), + SchemaTable( + 'ProgrammerTask', + columns: { + SchemaColumn( + '_brick_id', + Column.integer, + autoincrement: true, + nullable: false, + isPrimaryKey: true, + ), + SchemaColumn('id', Column.varchar), + SchemaColumn('title', Column.varchar), + SchemaColumn('description', Column.varchar), + SchemaColumn('category', Column.varchar), + SchemaColumn('status', Column.varchar), + SchemaColumn('priority', Column.integer), + SchemaColumn('assignee_id', Column.varchar), + SchemaColumn('creator_id', Column.varchar), + SchemaColumn('created_at', Column.datetime), + SchemaColumn('started_at', Column.datetime), + SchemaColumn('completed_at', Column.datetime), + SchemaColumn('cancelled_at', Column.datetime), + SchemaColumn('cancellation_reason', Column.varchar), + SchemaColumn('updated_at', Column.datetime), + SchemaColumn('is_terminal', Column.boolean), + }, + indices: {}, + ), + SchemaTable( + 'ProgrammerTaskActivityLog', + columns: { + SchemaColumn( + '_brick_id', + Column.integer, + autoincrement: true, + nullable: false, + isPrimaryKey: true, + ), + SchemaColumn('id', Column.varchar), + SchemaColumn('task_id', Column.varchar), + SchemaColumn('actor_id', Column.varchar), + SchemaColumn('action_type', Column.varchar), + SchemaColumn('meta', Column.varchar), + SchemaColumn('created_at', Column.datetime), + }, + indices: {}, + ), SchemaTable( 'Service', columns: { diff --git a/lib/models/programmer_task.model.dart b/lib/models/programmer_task.model.dart new file mode 100644 index 00000000..5b980075 --- /dev/null +++ b/lib/models/programmer_task.model.dart @@ -0,0 +1,145 @@ +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'; + +/// Work categories for a programmer task. Values are the TitleCase display +/// strings stored directly in the `programmer_task_category` Postgres enum +/// (same convention as the IT-support request_type / request_category enums). +class ProgrammerTaskCategory { + static const softwareSupport = 'Software Support'; + static const softwareDevelopment = 'Software Development'; + static const softwareEnhancement = 'Software Enhancement'; + static const bugFix = 'Bug Fix'; + static const meeting = 'Meeting'; + static const facilitateEvent = 'Facilitate Event'; + static const others = 'Others'; + + static const all = [ + softwareSupport, + softwareDevelopment, + softwareEnhancement, + bugFix, + meeting, + facilitateEvent, + others, + ]; + + /// Enum values already read as display labels, but this keeps the call site + /// symmetric with other models and guards unknown values. + static String label(String category) => + all.contains(category) ? category : category; +} + +/// Status lifecycle for a programmer task. +/// +/// `queued -> in_progress -> completed | cancelled`. Pause/resume are recorded +/// as activity-log events (not statuses): a paused task keeps `inProgress`. +class ProgrammerTaskStatus { + static const queued = 'queued'; + static const inProgress = 'in_progress'; + static const completed = 'completed'; + static const cancelled = 'cancelled'; + + static const all = [queued, inProgress, completed, cancelled]; + + static const terminal = [completed, cancelled]; + + static String label(String status) { + switch (status) { + case queued: + return 'Queued'; + case inProgress: + return 'In Progress'; + case completed: + return 'Completed'; + case cancelled: + return 'Cancelled'; + default: + return status; + } + } +} + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'programmer_tasks'), +) +class ProgrammerTask extends OfflineFirstWithSupabaseModel { + final String id; + final String title; + final String? description; + final String category; + final String status; + final int priority; + final String? assigneeId; + final String? creatorId; + final DateTime createdAt; + final DateTime? startedAt; + final DateTime? completedAt; + final DateTime? cancelledAt; + final String? cancellationReason; + final DateTime updatedAt; + + ProgrammerTask({ + required this.id, + required this.title, + this.description, + required this.category, + required this.status, + this.priority = 1, + this.assigneeId, + this.creatorId, + required this.createdAt, + this.startedAt, + this.completedAt, + this.cancelledAt, + this.cancellationReason, + required this.updatedAt, + }); + + bool get isTerminal => ProgrammerTaskStatus.terminal.contains(status); + + @Supabase(ignore: true) + @Sqlite(ignore: true) + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProgrammerTask && + runtimeType == other.runtimeType && + id == other.id && + status == other.status && + updatedAt == other.updatedAt; + + @Supabase(ignore: true) + @Sqlite(ignore: true) + @override + int get hashCode => Object.hash(id, status, updatedAt); + + factory ProgrammerTask.fromMap(Map map) { + return ProgrammerTask( + id: map['id'].toString(), + title: map['title'] as String? ?? '', + description: map['description'] as String?, + category: map['category'] as String? ?? ProgrammerTaskCategory.others, + status: map['status'] as String? ?? ProgrammerTaskStatus.queued, + priority: (map['priority'] as num?)?.toInt() ?? 1, + assigneeId: map['assignee_id']?.toString(), + creatorId: map['creator_id']?.toString(), + createdAt: AppTime.parse(map['created_at'] as String), + startedAt: map['started_at'] == null + ? null + : AppTime.parse(map['started_at'] as String), + completedAt: map['completed_at'] == null + ? null + : AppTime.parse(map['completed_at'] as String), + cancelledAt: map['cancelled_at'] == null + ? null + : AppTime.parse(map['cancelled_at'] as String), + cancellationReason: map['cancellation_reason'] 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_activity_log.model.dart b/lib/models/programmer_task_activity_log.model.dart new file mode 100644 index 00000000..d27fcdeb --- /dev/null +++ b/lib/models/programmer_task_activity_log.model.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +import '../utils/app_time.dart'; + +/// Event log for a programmer task. Mirrors [TaskActivityLog] but keyed to +/// `programmer_tasks`. `actionType` values: created, started, paused, resumed, +/// completed, cancelled, assigned, reassigned. +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'programmer_task_activity_logs'), +) +class ProgrammerTaskActivityLog extends OfflineFirstWithSupabaseModel { + final String id; + final String taskId; + final String? actorId; + final String actionType; + final Map? meta; + final DateTime createdAt; + + ProgrammerTaskActivityLog({ + required this.id, + required this.taskId, + this.actorId, + required this.actionType, + this.meta, + required this.createdAt, + }); + + factory ProgrammerTaskActivityLog.fromMap(Map map) { + final id = map['id'] == null ? '' : map['id'].toString(); + final taskId = map['task_id'] == null ? '' : map['task_id'].toString(); + final actorId = map['actor_id']?.toString(); + final actionType = (map['action_type'] as String?) ?? 'unknown'; + + Map? meta; + final rawMeta = map['meta']; + if (rawMeta is Map) { + meta = rawMeta; + } else if (rawMeta is Map) { + meta = rawMeta.map((k, v) => MapEntry(k.toString(), v)); + } else if (rawMeta is String && rawMeta.isNotEmpty) { + try { + final decoded = jsonDecode(rawMeta); + if (decoded is Map) { + meta = decoded.map((k, v) => MapEntry(k.toString(), v)); + } + } catch (_) { + meta = null; + } + } + + final rawCreated = map['created_at']; + DateTime createdAt; + if (rawCreated is DateTime) { + createdAt = AppTime.toAppTime(rawCreated); + } else if (rawCreated is String) { + try { + createdAt = AppTime.parse(rawCreated); + } catch (_) { + createdAt = AppTime.now(); + } + } else { + createdAt = AppTime.now(); + } + + return ProgrammerTaskActivityLog( + id: id, + taskId: taskId, + actorId: actorId, + actionType: actionType, + meta: meta, + createdAt: createdAt, + ); + } +} diff --git a/lib/providers/programmer_tasks_provider.dart b/lib/providers/programmer_tasks_provider.dart new file mode 100644 index 00000000..88080985 --- /dev/null +++ b/lib/providers/programmer_tasks_provider.dart @@ -0,0 +1,592 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:uuid/uuid.dart'; + +import '../brick/cache_helpers.dart'; +import '../models/programmer_task.model.dart'; +import '../models/programmer_task_activity_log.model.dart'; +import '../utils/snackbar.dart' show isOfflineSaveError; +import 'connectivity_provider.dart'; +import 'profile_provider.dart'; +import 'realtime_controller.dart'; +import 'stream_recovery.dart'; +import 'supabase_provider.dart'; + +/// Lifecycle action types that determine whether a task is currently paused. +const _pauseStateActions = ['started', 'paused', 'resumed', 'completed', 'cancelled']; + +int _statusRank(String status) { + switch (status) { + case ProgrammerTaskStatus.inProgress: + return 0; + case ProgrammerTaskStatus.queued: + return 1; + case ProgrammerTaskStatus.completed: + return 2; + case ProgrammerTaskStatus.cancelled: + return 3; + default: + return 4; + } +} + +void _sortTasks(List tasks) { + tasks.sort((a, b) { + final r = _statusRank(a.status).compareTo(_statusRank(b.status)); + if (r != 0) return r; + return b.createdAt.compareTo(a.createdAt); + }); +} + +// --------------------------------------------------------------------------- +// Offline-pending state (merged into the live stream; replayed on reconnect) +// --------------------------------------------------------------------------- + +final offlinePendingProgrammerTasksProvider = + StateProvider>((ref) => const []); + +final offlinePendingProgrammerTaskRawProvider = + StateProvider>>((ref) => const []); + +final offlinePendingProgrammerTaskUpdatesProvider = + StateProvider>>((ref) => const {}); + +final offlinePendingProgrammerTaskLogsProvider = + StateProvider>>((ref) => const []); + +// --------------------------------------------------------------------------- +// Stream providers +// --------------------------------------------------------------------------- + +final programmerTasksProvider = StreamProvider>((ref) { + final userId = ref.watch(currentUserIdProvider); + if (userId == null) return const Stream.empty(); + final client = ref.watch(supabaseClientProvider); + + final pendingNew = ref.watch(offlinePendingProgrammerTasksProvider); + final pendingUpdates = ref.watch(offlinePendingProgrammerTaskUpdatesProvider); + + List applyPending(List rows) { + final byId = {for (final r in rows) r.id: r}; + pendingUpdates.forEach((id, fields) { + final existing = byId[id]; + if (existing == null) return; + byId[id] = _applyUpdateFields(existing, fields); + }); + for (final p in pendingNew) { + byId.putIfAbsent(p.id, () => p); + } + final result = byId.values.toList(); + _sortTasks(result); + return result; + } + + final wrapper = StreamRecoveryWrapper( + stream: client + .from('programmer_tasks') + .stream(primaryKey: ['id']) + .order('created_at', ascending: false), + onPollData: () async { + final data = await client + .from('programmer_tasks') + .select() + .order('created_at', ascending: false) + .range(0, 199); + return data.map(ProgrammerTask.fromMap).toList(); + }, + fromMap: ProgrammerTask.fromMap, + channelName: 'programmer_tasks', + onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + onOfflineData: () async { + final all = await cachedListFromBrick(); + return applyPending(all); + }, + onCacheMirror: (rows) => + mirrorBatchToBrick(rows, tag: 'programmer_tasks'), + ); + + ref.onDispose(wrapper.dispose); + + // Reconnect-replay: POST queued items directly, bypassing Brick's queue. + ref.listen(isOnlineProvider, (wasOnline, isNowOnline) async { + if (wasOnline == true || !isNowOnline) return; + + // 1. Replay offline-created tasks. + final pendingRaw = List>.from( + ref.read(offlinePendingProgrammerTaskRawProvider), + ); + if (pendingRaw.isNotEmpty) { + final syncedIds = []; + for (final payload in pendingRaw) { + final id = payload['id'] as String; + try { + await client.from('programmer_tasks').insert(payload); + try { + await client.from('programmer_task_activity_logs').insert({ + 'task_id': id, + 'actor_id': payload['creator_id'], + 'action_type': 'created', + }); + } catch (_) {} + syncedIds.add(id); + } catch (e) { + final msg = e.toString(); + if (msg.contains('23505') || msg.contains('duplicate key')) { + syncedIds.add(id); + } else { + debugPrint('[programmerTasksProvider] failed to sync id=$id: $e'); + } + } + } + if (syncedIds.isNotEmpty) { + ref.read(offlinePendingProgrammerTaskRawProvider.notifier).state = + List.unmodifiable( + ref + .read(offlinePendingProgrammerTaskRawProvider) + .where((p) => !syncedIds.contains(p['id'] as String)), + ); + ref.read(offlinePendingProgrammerTasksProvider.notifier).state = + List.unmodifiable( + ref + .read(offlinePendingProgrammerTasksProvider) + .where((t) => !syncedIds.contains(t.id)), + ); + } + } + + // 2. Replay offline field/status updates. + final pendingUpdatesMap = Map>.from( + ref.read(offlinePendingProgrammerTaskUpdatesProvider), + ); + if (pendingUpdatesMap.isNotEmpty) { + final syncedIds = []; + for (final entry in pendingUpdatesMap.entries) { + try { + await client + .from('programmer_tasks') + .update(entry.value) + .eq('id', entry.key); + syncedIds.add(entry.key); + } catch (e) { + debugPrint( + '[programmerTasksProvider] failed to sync update id=${entry.key}: $e', + ); + } + } + if (syncedIds.isNotEmpty) { + final remaining = Map>.from( + ref.read(offlinePendingProgrammerTaskUpdatesProvider), + ); + for (final id in syncedIds) { + remaining.remove(id); + } + ref.read(offlinePendingProgrammerTaskUpdatesProvider.notifier).state = + remaining; + } + } + + // 3. Replay queued activity logs (started/paused/resumed/etc.). + final pendingLogs = List>.from( + ref.read(offlinePendingProgrammerTaskLogsProvider), + ); + if (pendingLogs.isNotEmpty) { + final synced = >[]; + for (final log in pendingLogs) { + try { + await client.from('programmer_task_activity_logs').insert(log); + synced.add(log); + } catch (e) { + debugPrint('[programmerTasksProvider] failed to sync log: $e'); + } + } + if (synced.isNotEmpty) { + ref.read(offlinePendingProgrammerTaskLogsProvider.notifier).state = + List.unmodifiable( + ref + .read(offlinePendingProgrammerTaskLogsProvider) + .where((l) => !synced.contains(l)), + ); + } + } + }); + + return wrapper.stream.map((result) => applyPending(result.data)); +}); + +final programmerTaskByIdProvider = Provider.family(( + ref, + id, +) { + final tasks = ref.watch(programmerTasksProvider).valueOrNull; + if (tasks == null) return null; + try { + return tasks.firstWhere((t) => t.id == id); + } catch (_) { + return null; + } +}); + +/// The current user's actively-running task id (status in_progress AND latest +/// activity log is not a pause), or null. Lets the list distinguish a *running* +/// task from a *paused* one (both share the `in_progress` status). Re-queries +/// whenever the task list changes. Given the single-active invariant, at most +/// one task is running per user. +final myRunningProgrammerTaskIdProvider = FutureProvider((ref) async { + // Re-run whenever tasks change (a start/pause/resume mutates a row). + ref.watch(programmerTasksProvider); + final running = + await ref.watch(programmerTasksControllerProvider).findRunningTaskForCurrentUser(); + return running?.id; +}); + +final programmerTaskActivityLogsProvider = + StreamProvider.family, String>((ref, taskId) { + final client = ref.watch(supabaseClientProvider); + + final wrapper = StreamRecoveryWrapper( + stream: client + .from('programmer_task_activity_logs') + .stream(primaryKey: ['id']) + .eq('task_id', taskId) + .order('created_at', ascending: false), + onPollData: () async { + final data = await client + .from('programmer_task_activity_logs') + .select() + .eq('task_id', taskId) + .order('created_at', ascending: false); + return data.map(ProgrammerTaskActivityLog.fromMap).toList(); + }, + fromMap: ProgrammerTaskActivityLog.fromMap, + channelName: 'programmer_task_activity_logs_$taskId', + onStatusChanged: ref + .read(realtimeControllerProvider) + .handleChannelStatus, + onOfflineData: () async { + final all = await cachedListFromBrick(); + return all.where((l) => l.taskId == taskId).toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + }, + onCacheMirror: (rows) => mirrorBatchToBrick( + rows, + tag: 'programmer_task_activity_logs', + ), + ); + + ref.onDispose(wrapper.dispose); + return wrapper.stream.map((result) => result.data); + }); + +// --------------------------------------------------------------------------- +// Controller +// --------------------------------------------------------------------------- + +final programmerTasksControllerProvider = Provider(( + ref, +) { + final client = ref.watch(supabaseClientProvider); + return ProgrammerTasksController(client, ref); +}); + +class ProgrammerTasksController { + ProgrammerTasksController(this._client, [this._ref]); + + final dynamic _client; + final Ref? _ref; + + bool get _isOnline => _ref?.read(isOnlineProvider) ?? true; + + /// Whether [taskId]'s most recent lifecycle event is a pause (i.e. the task + /// is in_progress but not actively running). + Future _isCurrentlyPaused(String taskId) async { + try { + final rows = await _client + .from('programmer_task_activity_logs') + .select('action_type, created_at') + .eq('task_id', taskId) + .inFilter('action_type', _pauseStateActions) + .order('created_at', ascending: false) + .limit(1); + if (rows is List && rows.isNotEmpty) { + return (rows.first['action_type']?.toString() ?? '') == 'paused'; + } + } catch (_) {} + return false; + } + + Future _insertLog( + String taskId, + String actionType, { + Map? meta, + }) async { + final row = { + 'task_id': taskId, + 'actor_id': _client.auth.currentUser?.id, + 'action_type': actionType, + 'meta': ?meta, + }; + if (!_isOnline) { + _queueLog(row); + return; + } + try { + await _client.from('programmer_task_activity_logs').insert(row); + } catch (e) { + if (isOfflineSaveError(e)) { + _queueLog(row); + } else { + rethrow; + } + } + } + + /// Creates a task. [assigneeId] defaults to the current user (self-logged); + /// pass another programmer's id to assign it to them. Returns the task id. + Future createTask({ + required String title, + required String category, + String? description, + String? assigneeId, + int priority = 1, + }) async { + final userId = _client.auth.currentUser?.id; + if (userId == null) throw Exception('Not authenticated'); + + final id = const Uuid().v4(); + final assignee = assigneeId ?? userId; + final payload = { + 'id': id, + 'title': title, + 'description': description, + 'category': category, + 'status': ProgrammerTaskStatus.queued, + 'priority': priority, + 'assignee_id': assignee, + 'creator_id': userId, + }; + + try { + await _client.from('programmer_tasks').insert(payload); + await _insertLog(id, 'created'); + if (assignee != userId) { + await _insertLog(id, 'assigned', meta: {'assignee_id': assignee}); + } + return id; + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + final now = DateTime.now().toUtc(); + final local = ProgrammerTask( + id: id, + title: title, + description: description, + category: category, + status: ProgrammerTaskStatus.queued, + priority: priority, + assigneeId: assignee, + creatorId: userId, + createdAt: now, + updatedAt: now, + ); + _queueNewTask(local, payload); + return id; + } + } + + /// The current user's actively-running task (status in_progress, latest event + /// not a pause), or null. Used to drive the pause-on-switch prompt. Online + /// only — returns null offline (the prompt is a best-effort UX aid). + Future findRunningTaskForCurrentUser() async { + final userId = _client.auth.currentUser?.id; + if (userId == null || !_isOnline) return null; + try { + final rows = await _client + .from('programmer_tasks') + .select() + .eq('assignee_id', userId) + .eq('status', ProgrammerTaskStatus.inProgress); + if (rows is! List) return null; + for (final row in rows) { + final task = ProgrammerTask.fromMap(row as Map); + if (!await _isCurrentlyPaused(task.id)) return task; + } + } catch (_) {} + return null; + } + + /// Moves a queued task to in_progress (sets started_at on first start) and + /// logs a `started` event. + Future startTask({required String taskId}) async { + final updates = { + 'status': ProgrammerTaskStatus.inProgress, + 'started_at': DateTime.now().toUtc().toIso8601String(), + }; + await _updateTaskRow(taskId, updates, onlyIfStartNull: true); + await _insertLog(taskId, 'started'); + } + + /// Logs a `paused` event for a running task (idempotent). + Future pauseTask({required String taskId}) async { + if (_isOnline && await _isCurrentlyPaused(taskId)) return; + await _insertLog(taskId, 'paused'); + } + + /// Logs a `resumed` event for a paused task (idempotent). + Future resumeTask({required String taskId}) async { + if (_isOnline && !await _isCurrentlyPaused(taskId)) return; + await _insertLog(taskId, 'resumed'); + } + + Future completeTask({required String taskId}) async { + await _updateTaskRow(taskId, { + 'status': ProgrammerTaskStatus.completed, + 'completed_at': DateTime.now().toUtc().toIso8601String(), + }); + await _insertLog(taskId, 'completed'); + } + + Future cancelTask({ + required String taskId, + required String reason, + }) async { + await _updateTaskRow(taskId, { + 'status': ProgrammerTaskStatus.cancelled, + 'cancelled_at': DateTime.now().toUtc().toIso8601String(), + 'cancellation_reason': reason, + }); + await _insertLog(taskId, 'cancelled', meta: {'reason': reason}); + } + + Future reassign({ + required String taskId, + required String newAssigneeId, + }) async { + await _updateTaskRow(taskId, {'assignee_id': newAssigneeId}); + await _insertLog(taskId, 'reassigned', meta: {'assignee_id': newAssigneeId}); + } + + Future updateTask({ + required String taskId, + String? title, + String? description, + String? category, + int? priority, + }) async { + final updates = {}; + if (title != null) updates['title'] = title; + if (description != null) updates['description'] = description; + if (category != null) updates['category'] = category; + if (priority != null) updates['priority'] = priority; + if (updates.isEmpty) return; + await _updateTaskRow(taskId, updates); + await _insertLog(taskId, 'updated', meta: {'fields': updates.keys.toList()}); + } + + /// 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). + Future _updateTaskRow( + String taskId, + Map updates, { + bool onlyIfStartNull = false, + }) async { + var payload = updates; + if (!_isOnline) { + _queueUpdate(taskId, payload); + return; + } + try { + if (onlyIfStartNull && payload.containsKey('started_at')) { + final existing = await _client + .from('programmer_tasks') + .select('started_at') + .eq('id', taskId) + .maybeSingle(); + if (existing is Map && existing['started_at'] != null) { + payload = Map.from(payload)..remove('started_at'); + } + } + await _client.from('programmer_tasks').update(payload).eq('id', taskId); + } catch (e) { + if (isOfflineSaveError(e)) { + _queueUpdate(taskId, payload); + } else { + rethrow; + } + } + } + + // ------------------------------------------------------------------ queues + + void _queueNewTask(ProgrammerTask model, Map payload) { + final ref = _ref; + if (ref == null) return; + ref.read(offlinePendingProgrammerTasksProvider.notifier).state = + List.unmodifiable([ + ...ref.read(offlinePendingProgrammerTasksProvider), + model, + ]); + ref.read(offlinePendingProgrammerTaskRawProvider.notifier).state = + List.unmodifiable([ + ...ref.read(offlinePendingProgrammerTaskRawProvider), + payload, + ]); + mirrorBatchToBrick([model], tag: 'offline_programmer_task'); + } + + void _queueUpdate(String taskId, Map fields) { + final ref = _ref; + if (ref == null) return; + final current = Map>.from( + ref.read(offlinePendingProgrammerTaskUpdatesProvider), + ); + current[taskId] = {...?current[taskId], ...fields}; + ref.read(offlinePendingProgrammerTaskUpdatesProvider.notifier).state = + current; + } + + void _queueLog(Map row) { + final ref = _ref; + if (ref == null) return; + ref.read(offlinePendingProgrammerTaskLogsProvider.notifier).state = + List.unmodifiable([ + ...ref.read(offlinePendingProgrammerTaskLogsProvider), + row, + ]); + } +} + +// --------------------------------------------------------------------------- +// Field-patch helper for optimistic offline updates +// --------------------------------------------------------------------------- + +ProgrammerTask _applyUpdateFields(ProgrammerTask t, Map f) { + DateTime? parseOpt(String key) { + if (!f.containsKey(key)) return null; + final v = f[key]; + return v == null ? null : DateTime.parse(v as String).toUtc(); + } + + return ProgrammerTask( + id: t.id, + title: (f['title'] as String?) ?? t.title, + description: f.containsKey('description') + ? f['description'] as String? + : t.description, + category: (f['category'] as String?) ?? t.category, + status: (f['status'] as String?) ?? t.status, + priority: (f['priority'] as num?)?.toInt() ?? t.priority, + assigneeId: + f.containsKey('assignee_id') ? f['assignee_id'] as String? : t.assigneeId, + creatorId: t.creatorId, + createdAt: t.createdAt, + startedAt: f.containsKey('started_at') ? parseOpt('started_at') : t.startedAt, + completedAt: + f.containsKey('completed_at') ? parseOpt('completed_at') : t.completedAt, + cancelledAt: + f.containsKey('cancelled_at') ? parseOpt('cancelled_at') : t.cancelledAt, + cancellationReason: f.containsKey('cancellation_reason') + ? f['cancellation_reason'] as String? + : t.cancellationReason, + updatedAt: DateTime.now().toUtc(), + ); +} diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index 6f8888cc..ed388b6c 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -30,6 +30,8 @@ import '../widgets/app_shell.dart'; import '../screens/teams/teams_screen.dart'; 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/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'; @@ -93,6 +95,10 @@ final appRouterProvider = Provider((ref) { role == 'it_staff' || role == 'dispatcher' || role == 'programmer'; + final isProgrammerTasksRoute = + state.matchedLocation.startsWith('/programmer-tasks'); + final hasProgrammerTasksAccess = + role == 'admin' || role == 'programmer'; if (!isSignedIn && !isAuthRoute) { return '/login'; @@ -125,6 +131,9 @@ final appRouterProvider = Provider((ref) { if (isNetworkMapRoute && !hasNetworkMapAccess) { return '/tickets'; } + if (isProgrammerTasksRoute && !hasProgrammerTasksAccess) { + return '/tickets'; + } // Attendance & Whereabouts: not accessible to standard users final isStandardOnly = role == 'standard'; final isAttendanceRoute = @@ -207,6 +216,21 @@ final appRouterProvider = Provider((ref) { ), ], ), + GoRoute( + path: '/programmer-tasks', + pageBuilder: _page(const ProgrammerTasksListScreen()), + routes: [ + GoRoute( + path: ':id', + pageBuilder: (context, state) => M3ContainerTransformPage( + key: state.pageKey, + child: ProgrammerTaskDetailScreen( + taskId: state.pathParameters['id'] ?? '', + ), + ), + ), + ], + ), GoRoute( path: '/announcements', pageBuilder: _page(const AnnouncementsScreen()), diff --git a/lib/screens/programmer_tasks/programmer_task_actions.dart b/lib/screens/programmer_tasks/programmer_task_actions.dart new file mode 100644 index 00000000..60294bed --- /dev/null +++ b/lib/screens/programmer_tasks/programmer_task_actions.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../models/programmer_task.model.dart'; +import '../../providers/programmer_tasks_provider.dart'; + +/// Starts (or resumes) [task] for the current user, enforcing the single-active +/// task discipline: if the user already has a *running* task, they are prompted +/// to pause it first. This is the headline "proper time management" behavior and +/// is shared by the list and detail screens. +/// +/// Returns true if the task was started/resumed, false if the user cancelled. +Future startProgrammerTaskWithPausePrompt( + BuildContext context, + WidgetRef ref, + ProgrammerTask task, +) async { + final controller = ref.read(programmerTasksControllerProvider); + final messenger = ScaffoldMessenger.of(context); + + final running = await controller.findRunningTaskForCurrentUser(); + if (running != null && running.id != task.id) { + if (!context.mounted) return false; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + semanticLabel: 'Pause current task', + title: const Text('Pause current task?'), + content: Text( + "You're currently working on \"${running.title}\". " + 'Pause it and start "${task.title}"?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Pause & Start'), + ), + ], + ), + ); + if (confirmed != true) return false; + await controller.pauseTask(taskId: running.id); + } + + try { + if (task.status == ProgrammerTaskStatus.queued) { + await controller.startTask(taskId: task.id); + } else if (task.status == ProgrammerTaskStatus.inProgress) { + await controller.resumeTask(taskId: task.id); + } + messenger.showSnackBar( + SnackBar(content: Text('Started "${task.title}"')), + ); + return true; + } catch (e) { + messenger.showSnackBar(SnackBar(content: Text('Could not start task: $e'))); + return false; + } +} diff --git a/lib/screens/programmer_tasks/programmer_task_detail_screen.dart b/lib/screens/programmer_tasks/programmer_task_detail_screen.dart new file mode 100644 index 00000000..ac5df3f2 --- /dev/null +++ b/lib/screens/programmer_tasks/programmer_task_detail_screen.dart @@ -0,0 +1,475 @@ +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_activity_log.model.dart'; +import '../../providers/profile_provider.dart'; +import '../../providers/programmer_tasks_provider.dart'; +import '../../utils/app_time.dart'; +import '../../utils/task_duration.dart'; +import '../../widgets/app_section_header.dart'; +import '../../widgets/m3_card.dart'; +import '../../widgets/responsive_body.dart'; +import '../../widgets/status_pill.dart'; +import '../../widgets/tech_chip.dart'; +import 'programmer_task_actions.dart'; +import 'programmer_task_status_kind.dart'; + +/// Lifecycle events, newest-first, that determine the live running/paused state. +const _lifecycleActions = ['started', 'paused', 'resumed', 'completed', 'cancelled']; + +class ProgrammerTaskDetailScreen extends ConsumerWidget { + const ProgrammerTaskDetailScreen({super.key, required this.taskId}); + + final String taskId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final task = ref.watch(programmerTaskByIdProvider(taskId)); + final logsAsync = ref.watch(programmerTaskActivityLogsProvider(taskId)); + final userId = ref.watch(currentUserIdProvider); + final role = ref.watch(currentProfileProvider).valueOrNull?.role; + final profiles = ref.watch(profilesProvider).valueOrNull ?? const []; + final profileById = {for (final p in profiles) p.id: p}; + + return Scaffold( + appBar: AppBar( + title: Text(task?.title ?? 'Programmer Task'), + actions: [ + if (task != null && !task.isTerminal && _canAct(task, userId, role)) + IconButton( + tooltip: 'Reassign', + icon: const Icon(Icons.person_add_alt_1_outlined), + onPressed: () => _reassign(context, ref, task, profiles, userId), + ), + ], + ), + body: task == null + ? const Center(child: CircularProgressIndicator()) + : _DetailBody( + task: task, + logs: logsAsync.valueOrNull ?? const [], + profileById: profileById, + userId: userId, + role: role, + ), + ); + } +} + +bool _canAct(ProgrammerTask task, String? userId, String? role) => + task.assigneeId == userId || role == 'admin' || role == 'programmer'; + +class _DetailBody extends ConsumerWidget { + const _DetailBody({ + required this.task, + required this.logs, + required this.profileById, + required this.userId, + required this.role, + }); + + final ProgrammerTask task; + final List logs; + final Map profileById; + final String? userId; + final String? role; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tt = Theme.of(context).textTheme; + final cs = Theme.of(context).colorScheme; + + // Live running/paused state from the newest lifecycle event. + String latest = ''; + for (final l in logs) { + if (_lifecycleActions.contains(l.actionType)) { + latest = l.actionType; + break; + } + } + final isPaused = + task.status == ProgrammerTaskStatus.inProgress && latest == 'paused'; + final isRunning = + task.status == ProgrammerTaskStatus.inProgress && !isPaused; + + final endAt = switch (task.status) { + ProgrammerTaskStatus.completed => task.completedAt ?? AppTime.now(), + ProgrammerTaskStatus.cancelled => task.cancelledAt ?? AppTime.now(), + _ => AppTime.now(), + }; + final worked = computeEffectiveDuration( + fallbackStartedAt: task.startedAt, + endAt: endAt, + events: logs + .map((l) => (createdAt: l.createdAt, actionType: l.actionType)) + .toList(), + ); + + final assignee = + task.assigneeId == null ? null : profileById[task.assigneeId]; + final canAct = _canAct(task, userId, role); + + return 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( + task.title, + style: tt.titleLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + ), + StatusPill.kind( + programmerTaskStatusKind(task.status), + label: isPaused + ? 'Paused' + : ProgrammerTaskStatus.label(task.status), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + TechChip(task.category, icon: Icons.category_outlined), + if (assignee != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.person_outline, + size: 15, color: cs.onSurfaceVariant), + const SizedBox(width: 4), + Text( + assignee.fullName, + style: tt.bodyMedium + ?.copyWith(color: cs.onSurfaceVariant), + ), + ], + ), + ], + ), + if (task.description != null && + task.description!.trim().isNotEmpty) ...[ + const SizedBox(height: 14), + Text(task.description!, style: tt.bodyMedium), + ], + const SizedBox(height: 14), + Row( + children: [ + Icon(Icons.timer_outlined, size: 18, color: cs.primary), + const SizedBox(width: 8), + Text( + 'Worked: ${_fmtDuration(worked)}', + style: tt.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + if (isRunning) ...[ + const SizedBox(width: 8), + Text('· running', + style: tt.bodySmall + ?.copyWith(color: cs.onSurfaceVariant)), + ], + ], + ), + if (task.cancellationReason != null) ...[ + const SizedBox(height: 10), + Text( + 'Cancelled: ${task.cancellationReason}', + style: tt.bodySmall?.copyWith(color: cs.error), + ), + ], + ], + ), + ), + ), + if (canAct && !task.isTerminal) ...[ + const SizedBox(height: 16), + _ActionBar( + task: task, + isRunning: isRunning, + 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), + ], + ), + ); + } +} + +class _ActionBar extends ConsumerWidget { + const _ActionBar({ + required this.task, + required this.isRunning, + required this.isPaused, + }); + + final ProgrammerTask task; + final bool isRunning; + final bool isPaused; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = ref.read(programmerTasksControllerProvider); + final isQueued = task.status == ProgrammerTaskStatus.queued; + + return Wrap( + spacing: 10, + runSpacing: 10, + children: [ + if (isQueued) + FilledButton.icon( + onPressed: () => + startProgrammerTaskWithPausePrompt(context, ref, task), + icon: const Icon(Icons.play_arrow_rounded), + label: const Text('Start'), + ), + if (isPaused) + FilledButton.icon( + onPressed: () => + startProgrammerTaskWithPausePrompt(context, ref, task), + icon: const Icon(Icons.play_arrow_rounded), + label: const Text('Resume'), + ), + if (isRunning) + FilledButton.tonalIcon( + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + await controller.pauseTask(taskId: task.id); + messenger.showSnackBar(const SnackBar(content: Text('Paused'))); + }, + icon: const Icon(Icons.pause_rounded), + label: const Text('Pause'), + ), + if (isRunning || isPaused) + OutlinedButton.icon( + onPressed: () => _complete(context, ref, task), + icon: const Icon(Icons.check_rounded), + label: const Text('Complete'), + ), + OutlinedButton.icon( + onPressed: () => _cancel(context, ref, task), + icon: const Icon(Icons.close_rounded), + label: const Text('Cancel'), + ), + ], + ); + } +} + +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, + WidgetRef ref, + ProgrammerTask task, +) async { + final messenger = ScaffoldMessenger.of(context); + final ok = await showDialog( + context: context, + builder: (context) => AlertDialog( + semanticLabel: 'Complete task', + title: const Text('Complete task?'), + content: Text('Mark "${task.title}" as completed?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Complete'), + ), + ], + ), + ); + if (ok != true) return; + await ref.read(programmerTasksControllerProvider).completeTask(taskId: task.id); + messenger.showSnackBar(const SnackBar(content: Text('Task completed'))); +} + +Future _cancel( + BuildContext context, + WidgetRef ref, + ProgrammerTask task, +) async { + final reasonCtrl = TextEditingController(); + final reason = await showDialog( + context: context, + builder: (context) => AlertDialog( + semanticLabel: 'Cancel task', + title: const Text('Cancel task?'), + content: TextField( + controller: reasonCtrl, + autofocus: true, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'Reason', + hintText: 'Why is this being cancelled?', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Back'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(reasonCtrl.text.trim()), + child: const Text('Cancel Task'), + ), + ], + ), + ); + reasonCtrl.dispose(); + if (reason == null || reason.isEmpty) return; + if (!context.mounted) return; + final messenger = ScaffoldMessenger.of(context); + await ref + .read(programmerTasksControllerProvider) + .cancelTask(taskId: task.id, reason: reason); + messenger.showSnackBar(const SnackBar(content: Text('Task cancelled'))); +} + +Future _reassign( + BuildContext context, + WidgetRef ref, + ProgrammerTask task, + List profiles, + String? userId, +) async { + final eligible = profiles + .where((p) => p.role == 'programmer' || p.role == 'admin') + .toList() + ..sort((a, b) => a.fullName.compareTo(b.fullName)); + if (eligible.isEmpty) return; + + var selected = task.assigneeId ?? userId; + final chosen = await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + semanticLabel: 'Reassign task', + title: const Text('Reassign task'), + content: DropdownButtonFormField( + initialValue: eligible.any((p) => p.id == selected) ? selected : null, + 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(() => selected = v), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(selected), + child: const Text('Reassign'), + ), + ], + ), + ), + ); + if (chosen == null || chosen == task.assigneeId) return; + if (!context.mounted) return; + final messenger = ScaffoldMessenger.of(context); + await ref + .read(programmerTasksControllerProvider) + .reassign(taskId: task.id, newAssigneeId: chosen); + messenger.showSnackBar(const SnackBar(content: Text('Task reassigned'))); +} diff --git a/lib/screens/programmer_tasks/programmer_task_status_kind.dart b/lib/screens/programmer_tasks/programmer_task_status_kind.dart new file mode 100644 index 00000000..42a12e48 --- /dev/null +++ b/lib/screens/programmer_tasks/programmer_task_status_kind.dart @@ -0,0 +1,13 @@ +import '../../models/programmer_task.model.dart'; +import '../../theme/app_status_colors.dart'; + +/// Maps a programmer-task status to a semantic [StatusKind] so its color carries +/// consistent meaning across the list and detail screens +/// (grey=queued, blue=in progress, green=completed, grey=cancelled). +StatusKind programmerTaskStatusKind(String status) => switch (status) { + ProgrammerTaskStatus.queued => StatusKind.neutral, + ProgrammerTaskStatus.inProgress => StatusKind.info, + ProgrammerTaskStatus.completed => StatusKind.success, + ProgrammerTaskStatus.cancelled => StatusKind.neutral, + _ => StatusKind.neutral, +}; diff --git a/lib/screens/programmer_tasks/programmer_tasks_list_screen.dart b/lib/screens/programmer_tasks/programmer_tasks_list_screen.dart new file mode 100644 index 00000000..59eef322 --- /dev/null +++ b/lib/screens/programmer_tasks/programmer_tasks_list_screen.dart @@ -0,0 +1,354 @@ +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_task.model.dart'; +import '../../providers/profile_provider.dart'; +import '../../providers/programmer_tasks_provider.dart'; +import '../../theme/app_status_colors.dart'; +import '../../theme/m3_motion.dart'; +import '../../utils/app_time.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'; +import '../../widgets/tech_chip.dart'; +import 'programmer_task_actions.dart'; +import 'programmer_task_status_kind.dart'; + +class ProgrammerTasksListScreen extends ConsumerStatefulWidget { + const ProgrammerTasksListScreen({super.key}); + + @override + ConsumerState createState() => + _ProgrammerTasksListScreenState(); +} + +class _ProgrammerTasksListScreenState + extends ConsumerState + with SingleTickerProviderStateMixin { + late final TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _tabController.addListener(() { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final tasksAsync = ref.watch(programmerTasksProvider); + final userId = ref.watch(currentUserIdProvider); + final profilesAsync = ref.watch(profilesProvider); + + final profileById = { + for (final p in profilesAsync.valueOrNull ?? []) p.id: p, + }; + + return Stack( + children: [ + ResponsiveBody( + maxWidth: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const AppPageHeader( + title: 'Programmer Tasks', + subtitle: 'Log and track development work', + ), + TabBar( + controller: _tabController, + tabs: const [Tab(text: 'Mine'), Tab(text: 'All')], + ), + const SizedBox(height: 8), + Expanded( + child: Builder( + builder: (context) { + if (tasksAsync.hasError && !tasksAsync.hasValue) { + return AppErrorView( + error: tasksAsync.error!, + onRetry: () => + ref.invalidate(programmerTasksProvider), + ); + } + if (!tasksAsync.hasValue && tasksAsync.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + final all = tasksAsync.valueOrNull ?? const []; + final showMine = _tabController.index == 0; + final tasks = showMine + ? all.where((t) => t.assigneeId == userId).toList() + : all; + + if (tasks.isEmpty) { + return AppEmptyView( + icon: Icons.developer_mode_outlined, + title: showMine ? 'No tasks assigned to you' : 'No tasks yet', + subtitle: showMine + ? 'Tasks you log or that are assigned to you appear here.' + : 'Create the first programmer task with the + button.', + ); + } + + return ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: tasks.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (context, i) { + final task = tasks[i]; + return _TaskCard( + task: task, + assignee: task.assigneeId == null + ? null + : profileById[task.assigneeId], + isMine: task.assigneeId == userId, + ); + }, + ); + }, + ), + ), + ], + ), + ), + Positioned( + right: 16, + bottom: 16, + child: M3ExpandedFab( + heroTag: 'create_programmer_task', + onPressed: () => _showCreateDialog(context), + icon: const Icon(Icons.add), + label: const Text('New Task'), + ), + ), + const ReconnectIndicator(), + ], + ); + } + + 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 { + const _TaskCard({required this.task, required this.assignee, required this.isMine}); + + final ProgrammerTask task; + final Profile? assignee; + final bool isMine; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tt = Theme.of(context).textTheme; + final cs = Theme.of(context).colorScheme; + + // Distinguish a *paused* task from a *running* one (both share the + // `in_progress` status) using the current user's running-task id. + final runningId = ref.watch(myRunningProgrammerTaskIdProvider).valueOrNull; + final isInProgress = task.status == ProgrammerTaskStatus.inProgress; + final isPaused = isInProgress && isMine && task.id != runningId; + + final canStart = isMine && task.status == ProgrammerTaskStatus.queued; + final canResume = isPaused; + + final pillKind = + isPaused ? StatusKind.warning : programmerTaskStatusKind(task.status); + final pillLabel = + isPaused ? 'Paused' : ProgrammerTaskStatus.label(task.status); + + return M3Card.outlined( + onTap: () => context.go('/programmer-tasks/${task.id}'), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + task.title, + style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w600), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + StatusPill.kind(pillKind, label: pillLabel), + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + TechChip(task.category, icon: Icons.category_outlined, dense: true), + if (assignee != null && !isMine) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.person_outline, + size: 14, color: cs.onSurfaceVariant), + const SizedBox(width: 4), + Text( + assignee!.fullName, + style: + tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + ], + ), + Text( + AppTime.relative(task.createdAt), + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + ], + ), + if (canStart || canResume) ...[ + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: FilledButton.tonalIcon( + onPressed: () => + startProgrammerTaskWithPausePrompt(context, ref, task), + icon: const Icon(Icons.play_arrow_rounded, size: 18), + label: Text(canResume ? 'Resume' : 'Start'), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/utils/task_duration.dart b/lib/utils/task_duration.dart index b281fc81..1a2a6840 100644 --- a/lib/utils/task_duration.dart +++ b/lib/utils/task_duration.dart @@ -1,17 +1,25 @@ import '../models/task_activity_log.model.dart'; -/// Finds the canonical execution start time for a task. +/// A minimal activity event shape: just the fields the worked-duration math +/// needs. Both [TaskActivityLog] and ProgrammerTaskActivityLog map onto this, +/// so the pause-aware duration logic is shared across task modules. +typedef DurationEvent = ({DateTime createdAt, String actionType}); + +// --------------------------------------------------------------------------- +// Generic core (works on any DurationEvent list) +// --------------------------------------------------------------------------- + +/// Finds the canonical execution start time from [events]. /// -/// [logs] is expected in DESCENDING order (newest first), matching the -/// `order('created_at', ascending: false)` used by Supabase queries. -/// The reversed iteration therefore walks chronologically forward and stops -/// at the earliest 'started' event. -DateTime? resolveTaskExecutionStart( - List logs, +/// [events] is expected in DESCENDING order (newest first). The reversed +/// iteration walks chronologically forward and stops at the earliest 'started' +/// event, falling back to [fallbackStartedAt]. +DateTime? resolveExecutionStart( + List events, DateTime? fallbackStartedAt, ) { DateTime? started; - for (final entry in logs.reversed) { + for (final entry in events.reversed) { if (entry.actionType == 'started') { started = entry.createdAt; break; @@ -20,31 +28,30 @@ DateTime? resolveTaskExecutionStart( return started ?? fallbackStartedAt; } -/// Computes the effective worked duration for a task, subtracting all -/// paused intervals between [fallbackStartedAt] (or the first 'started' -/// log event) and [endAt]. +/// Computes the effective worked duration, subtracting all paused intervals +/// between the resolved start and [endAt]. /// -/// [logs] must be in DESCENDING order (newest first) — the same order -/// returned by `task_activity_logs` queries with `ascending: false`. -Duration computeEffectiveTaskDuration({ +/// [events] must be in DESCENDING order (newest first) — the same order +/// returned by activity-log queries with `ascending: false`. +Duration computeEffectiveDuration({ required DateTime? fallbackStartedAt, required DateTime endAt, - required List logs, + required List events, }) { - final start = resolveTaskExecutionStart(logs, fallbackStartedAt); + final start = resolveExecutionStart(events, fallbackStartedAt); if (start == null || !endAt.isAfter(start)) return Duration.zero; Duration pausedTotal = Duration.zero; DateTime? pausedSince; - // logs.reversed → ascending (chronological) order within [start, endAt] - final events = logs.reversed.where((e) { + // events.reversed → ascending (chronological) order within [start, endAt] + final relevant = events.reversed.where((e) { if (e.createdAt.isBefore(start)) return false; if (e.createdAt.isAfter(endAt)) return false; return e.actionType == 'paused' || e.actionType == 'resumed'; }); - for (final event in events) { + for (final event in relevant) { if (event.actionType == 'paused') { pausedSince ??= event.createdAt; } else if (event.actionType == 'resumed' && pausedSince != null) { @@ -55,7 +62,7 @@ Duration computeEffectiveTaskDuration({ } } - // Task is still paused at endAt — count the open pause interval. + // Still paused at endAt — count the open pause interval. if (pausedSince != null && endAt.isAfter(pausedSince)) { pausedTotal += endAt.difference(pausedSince); } @@ -63,3 +70,29 @@ Duration computeEffectiveTaskDuration({ final total = endAt.difference(start) - pausedTotal; return total.isNegative ? Duration.zero : total; } + +// --------------------------------------------------------------------------- +// TaskActivityLog wrappers (unchanged public API — existing call sites intact) +// --------------------------------------------------------------------------- + +List _toEvents(List logs) => + logs.map((l) => (createdAt: l.createdAt, actionType: l.actionType)).toList(); + +/// Finds the canonical execution start time for a task. See +/// [resolveExecutionStart]. +DateTime? resolveTaskExecutionStart( + List logs, + DateTime? fallbackStartedAt, +) => resolveExecutionStart(_toEvents(logs), fallbackStartedAt); + +/// Computes the effective worked duration for a task. See +/// [computeEffectiveDuration]. +Duration computeEffectiveTaskDuration({ + required DateTime? fallbackStartedAt, + required DateTime endAt, + required List logs, +}) => computeEffectiveDuration( + fallbackStartedAt: fallbackStartedAt, + endAt: endAt, + events: _toEvents(logs), +); diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 4d3091c3..0faf5174 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -460,6 +460,13 @@ List _buildSections(String role) { icon: Icons.task_outlined, selectedIcon: Icons.task, ), + if (role == 'admin' || role == 'programmer') + NavItem( + label: 'Programmer Tasks', + route: '/programmer-tasks', + icon: Icons.developer_mode_outlined, + selectedIcon: Icons.developer_mode, + ), NavItem( label: 'IT Service Requests', route: '/it-service-requests', @@ -715,6 +722,7 @@ String _routeToTitle(String location) { '/attendance' => 'Attendance', '/tickets' => 'Tickets', '/tasks' => 'Tasks', + '/programmer-tasks' => 'Programmer Tasks', '/it-service-requests' => 'IT Service Requests', '/network-map' => 'Network Map', '/notifications' => 'Notifications', diff --git a/supabase/migrations/20260927120000_add_programmer_tasks.sql b/supabase/migrations/20260927120000_add_programmer_tasks.sql new file mode 100644 index 00000000..ca37b0a1 --- /dev/null +++ b/supabase/migrations/20260927120000_add_programmer_tasks.sql @@ -0,0 +1,168 @@ +-- Programmer Tasks — a separate work-log / time-tracking module for the +-- `programmer` role. Distinct from the IT help-desk `tasks` feature: no office/ +-- ticket/it_staff coupling, its own dev-flavored category vocabulary, a single +-- assignee, and pause/resume tracked as activity-log events for time attribution. +-- Designed as the seed of a future Project Management workflow (projects/subtasks +-- can be added later without touching the help-desk `tasks` feature). + +-- Category vocabulary (TitleCase display strings, matching the existing enum +-- convention used by request_type / request_category). +create type programmer_task_category as enum ( + 'Software Support', + 'Software Development', + 'Software Enhancement', + 'Bug Fix', + 'Meeting', + 'Facilitate Event', + 'Others' +); + +create table if not exists programmer_tasks ( + id uuid primary key default gen_random_uuid(), + title text not null, + description text, + category programmer_task_category not null, + status text not null default 'queued', + priority int not null default 1, + assignee_id uuid references profiles(id), + creator_id uuid references profiles(id), + created_at timestamptz not null default now(), + started_at timestamptz, + completed_at timestamptz, + cancelled_at timestamptz, + cancellation_reason text, + updated_at timestamptz not null default now() +); + +create index if not exists idx_programmer_tasks_assignee_id on programmer_tasks(assignee_id); +create index if not exists idx_programmer_tasks_status on programmer_tasks(status); + +-- Keep updated_at fresh on every write (dedicated function to avoid clashing +-- with any generic trigger function elsewhere). +create or replace function set_programmer_tasks_updated_at() +returns trigger as $$ +begin + new.updated_at = now(); + return new; +end; +$$ language plpgsql; + +drop trigger if exists trg_programmer_tasks_updated_at on programmer_tasks; +create trigger trg_programmer_tasks_updated_at + before update on programmer_tasks + for each row execute function set_programmer_tasks_updated_at(); + +-- Activity log — mirrors task_activity_logs. Records created/started/paused/ +-- resumed/completed/cancelled/assigned/reassigned events. Pause/resume events +-- drive the effective-worked-duration math (see lib/utils/task_duration.dart). +create table if not exists programmer_task_activity_logs ( + id uuid primary key default gen_random_uuid(), + task_id uuid not null references programmer_tasks(id) on delete cascade, + actor_id uuid references profiles(id), + action_type text not null, + meta jsonb, + created_at timestamptz not null default now() +); + +create index if not exists idx_programmer_task_activity_logs_task_id + on programmer_task_activity_logs(task_id); + +-- --------------------------------------------------------------------------- +-- Row Level Security +-- --------------------------------------------------------------------------- +-- Programmer tasks are a programmer-team tool: admins and programmers have full +-- access; a row's assignee or creator can always see/act on their own task. + +alter table programmer_tasks enable row level security; +alter table programmer_task_activity_logs enable row level security; + +drop policy if exists "programmer_tasks_select" on programmer_tasks; +create policy "programmer_tasks_select" on programmer_tasks + for select to authenticated + using ( + assignee_id = auth.uid() + or 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_tasks_insert" on programmer_tasks; +create policy "programmer_tasks_insert" on programmer_tasks + 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_tasks_update" on programmer_tasks; +create policy "programmer_tasks_update" on programmer_tasks + for update to authenticated + using ( + assignee_id = auth.uid() + or creator_id = auth.uid() + or exists ( + select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer') + ) + ) + with check ( + assignee_id = auth.uid() + or 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_tasks_delete" on programmer_tasks; +create policy "programmer_tasks_delete" on programmer_tasks + 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') + ) + ); + +-- Activity logs: visible/writable to anyone who can see the parent task. +drop policy if exists "programmer_task_activity_logs_select" on programmer_task_activity_logs; +create policy "programmer_task_activity_logs_select" on programmer_task_activity_logs + for select to authenticated + using ( + 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') + ) + ) + ) + ); + +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 ( + 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') + ) + ) + ) + ); + +-- --------------------------------------------------------------------------- +-- Realtime — the Riverpod .stream() subscriptions require these tables in the +-- supabase_realtime publication. +-- --------------------------------------------------------------------------- +alter publication supabase_realtime add table programmer_tasks; +alter publication supabase_realtime add table programmer_task_activity_logs; diff --git a/test/programmer_task_duration_test.dart b/test/programmer_task_duration_test.dart new file mode 100644 index 00000000..7c5991a2 --- /dev/null +++ b/test/programmer_task_duration_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tasq/utils/task_duration.dart'; + +DurationEvent _ev(DateTime at, String action) => + (createdAt: at, actionType: action); + +void main() { + final base = DateTime.utc(2026, 1, 1, 9, 0); + + group('computeEffectiveDuration', () { + test('no pause events → full span from fallbackStartedAt to endAt', () { + final worked = computeEffectiveDuration( + fallbackStartedAt: base, + endAt: base.add(const Duration(hours: 2)), + events: const [], + ); + expect(worked, const Duration(hours: 2)); + }); + + test('resolves start from the "started" event over the fallback', () { + final started = base.add(const Duration(minutes: 30)); + // events are newest-first (DESC) + final events = [_ev(started, 'started')]; + final worked = computeEffectiveDuration( + fallbackStartedAt: base, // earlier fallback should be ignored + endAt: started.add(const Duration(hours: 1)), + events: events, + ); + expect(worked, const Duration(hours: 1)); + }); + + test('subtracts a completed paused interval', () { + final started = base; + final paused = base.add(const Duration(minutes: 20)); + final resumed = base.add(const Duration(minutes: 50)); // 30m paused + final end = base.add(const Duration(hours: 2)); + // DESC order + final events = [ + _ev(resumed, 'resumed'), + _ev(paused, 'paused'), + _ev(started, 'started'), + ]; + final worked = computeEffectiveDuration( + fallbackStartedAt: started, + endAt: end, + events: events, + ); + expect(worked, const Duration(hours: 2) - const Duration(minutes: 30)); + }); + + test('counts an open pause interval still active at endAt', () { + final started = base; + final paused = base.add(const Duration(minutes: 40)); + final end = base.add(const Duration(hours: 1)); // 20m still paused + final events = [ + _ev(paused, 'paused'), + _ev(started, 'started'), + ]; + final worked = computeEffectiveDuration( + fallbackStartedAt: started, + endAt: end, + events: events, + ); + expect(worked, const Duration(minutes: 40)); + }); + + test('returns zero when there is no resolvable start', () { + final worked = computeEffectiveDuration( + fallbackStartedAt: null, + endAt: base, + events: const [], + ); + expect(worked, Duration.zero); + }); + + test('returns zero when endAt is before start', () { + final worked = computeEffectiveDuration( + fallbackStartedAt: base, + endAt: base.subtract(const Duration(minutes: 5)), + events: const [], + ); + expect(worked, Duration.zero); + }); + }); +} diff --git a/test/programmer_tasks_controller_test.dart b/test/programmer_tasks_controller_test.dart new file mode 100644 index 00000000..e912db27 --- /dev/null +++ b/test/programmer_tasks_controller_test.dart @@ -0,0 +1,462 @@ +import 'dart:async'; + +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_tasks_provider.dart'; +import 'package:tasq/screens/programmer_tasks/programmer_task_actions.dart'; +import 'package:tasq/utils/app_time.dart'; + +// --------------------------------------------------------------------------- +// Minimal fake Supabase client — only the surface ProgrammerTasksController uses. +// --------------------------------------------------------------------------- + +class _FakeUser { + _FakeUser(this.id); + final String id; +} + +class _FakeAuth { + _FakeAuth(this._id); + final String _id; + _FakeUser? get currentUser => _FakeUser(_id); +} + +class _FakeClient { + _FakeClient(this.userId); + final String userId; + int _seq = 0; + + final Map>> tables = { + 'programmer_tasks': [], + 'programmer_task_activity_logs': [], + }; + + _FakeAuth get auth => _FakeAuth(userId); + + _FakeQuery from(String table) => _FakeQuery(this, table); + + String nextTimestamp() { + _seq += 1; + return DateTime.utc(2026, 1, 1).add(Duration(seconds: _seq)).toIso8601String(); + } +} + +class _FakeQuery implements Future>> { + _FakeQuery(this.client, this.table); + + final _FakeClient client; + final String table; + + final List> _eqs = []; + String? _inField; + List? _inValues; + String? _orderField; + bool _orderAsc = true; + int? _limit; + Map? _insertPayload; + Map? _updatePayload; + + List> get _rows { + var rows = List>.from(client.tables[table] ?? []); + if (_inField != null && _inValues != null) { + rows = rows + .where((r) => _inValues!.contains(r[_inField]?.toString())) + .toList(); + } + for (final eq in _eqs) { + rows = rows.where((r) => r[eq.key] == eq.value).toList(); + } + if (_orderField != null) { + rows.sort((a, b) { + final av = '${a[_orderField]}'; + final bv = '${b[_orderField]}'; + return _orderAsc ? av.compareTo(bv) : bv.compareTo(av); + }); + } + if (_limit != null && rows.length > _limit!) { + rows = rows.sublist(0, _limit!); + } + return rows; + } + + Future>> get _future async { + if (_insertPayload != null) { + final row = Map.from(_insertPayload!); + row['id'] ??= '${table}_${client.tables[table]!.length + 1}'; + row['created_at'] ??= client.nextTimestamp(); + if (table == 'programmer_tasks') { + row['updated_at'] ??= row['created_at']; + } + client.tables[table]!.add(row); + return [row]; + } + return _rows; + } + + _FakeQuery select([String? _]) => this; + + _FakeQuery insert(Map payload) { + _insertPayload = Map.from(payload); + return this; + } + + _FakeQuery update(Map payload) { + _updatePayload = Map.from(payload); + return this; + } + + _FakeQuery inFilter(String field, List values) { + _inField = field; + _inValues = values.map((v) => v.toString()).toList(); + return this; + } + + _FakeQuery order(String field, {bool ascending = true}) { + _orderField = field; + _orderAsc = ascending; + return this; + } + + _FakeQuery limit(int n) { + _limit = n; + return this; + } + + _FakeQuery eq(String field, dynamic value) { + if (_updatePayload != null) { + final idx = + client.tables[table]!.indexWhere((r) => r[field] == value); + if (idx >= 0) { + client.tables[table]![idx] = { + ...client.tables[table]![idx], + ..._updatePayload!, + }; + } + _updatePayload = null; + return this; + } + _eqs.add(MapEntry(field, value)); + return this; + } + + Future?> maybeSingle() async { + final rows = _rows; + return rows.isEmpty ? null : Map.from(rows.first); + } + + // Future delegation. + @override + Stream>> asStream() => _future.asStream(); + @override + Future>> catchError(Function onError, + {bool Function(Object error)? test}) => + _future.catchError(onError, test: test); + @override + Future then(FutureOr Function(List>) onValue, + {Function? onError}) => + _future.then(onValue, onError: onError); + @override + Future>> timeout(Duration timeLimit, + {FutureOr>> Function()? onTimeout}) => + _future.timeout(timeLimit, onTimeout: onTimeout); + @override + Future>> whenComplete(FutureOr Function() a) => + _future.whenComplete(a); +} + +List> _logs(_FakeClient c) => + c.tables['programmer_task_activity_logs']!; +Map _task(_FakeClient c, String id) => + c.tables['programmer_tasks']!.firstWhere((t) => t['id'] == id); + +void main() { + setUpAll(AppTime.initialize); + + group('ProgrammerTasksController.createTask', () { + test('self-logged task: assignee = creator, no "assigned" log', () async { + final fake = _FakeClient('u1'); + final c = ProgrammerTasksController(fake as dynamic); + + final id = await c.createTask( + title: 'Fix login bug', + category: ProgrammerTaskCategory.bugFix, + ); + + final row = _task(fake, id); + expect(row['assignee_id'], 'u1'); + expect(row['creator_id'], 'u1'); + expect(row['status'], ProgrammerTaskStatus.queued); + expect(_logs(fake).where((l) => l['action_type'] == 'created').length, 1); + expect(_logs(fake).any((l) => l['action_type'] == 'assigned'), isFalse); + }); + + test('assigned to another programmer records an "assigned" log', () async { + final fake = _FakeClient('u1'); + final c = ProgrammerTasksController(fake as dynamic); + + final id = await c.createTask( + title: 'Build report export', + category: ProgrammerTaskCategory.softwareDevelopment, + assigneeId: 'u2', + ); + + expect(_task(fake, id)['assignee_id'], 'u2'); + final assigned = + _logs(fake).where((l) => l['action_type'] == 'assigned').toList(); + expect(assigned.length, 1); + expect(assigned.first['meta'], {'assignee_id': 'u2'}); + }); + }); + + group('findRunningTaskForCurrentUser (pause-on-switch detection)', () { + test('returns null when the user has no in_progress task', () async { + final fake = _FakeClient('u1'); + final c = ProgrammerTasksController(fake as dynamic); + fake.tables['programmer_tasks']!.add({ + 'id': 't1', + 'assignee_id': 'u1', + 'status': ProgrammerTaskStatus.queued, + 'created_at': fake.nextTimestamp(), + }); + expect(await c.findRunningTaskForCurrentUser(), isNull); + }); + + test('returns an in_progress task whose latest event is "started"', () async { + final fake = _FakeClient('u1'); + final c = ProgrammerTasksController(fake as dynamic); + fake.tables['programmer_tasks']!.add({ + 'id': 't1', + 'title': 'Running task', + 'category': ProgrammerTaskCategory.others, + 'assignee_id': 'u1', + 'status': ProgrammerTaskStatus.inProgress, + 'created_at': fake.nextTimestamp(), + }); + _logs(fake).add({ + 'task_id': 't1', + 'action_type': 'started', + 'created_at': fake.nextTimestamp(), + }); + + final running = await c.findRunningTaskForCurrentUser(); + expect(running, isNotNull); + expect(running!.id, 't1'); + }); + + test('a paused in_progress task does NOT count as running', () async { + final fake = _FakeClient('u1'); + final c = ProgrammerTasksController(fake as dynamic); + fake.tables['programmer_tasks']!.add({ + 'id': 't1', + 'title': 'Paused task', + 'category': ProgrammerTaskCategory.others, + 'assignee_id': 'u1', + 'status': ProgrammerTaskStatus.inProgress, + 'created_at': fake.nextTimestamp(), + }); + _logs(fake).add({ + 'task_id': 't1', + 'action_type': 'started', + 'created_at': fake.nextTimestamp(), + }); + _logs(fake).add({ + 'task_id': 't1', + 'action_type': 'paused', + 'created_at': fake.nextTimestamp(), + }); + + expect(await c.findRunningTaskForCurrentUser(), isNull); + }); + }); + + group('lifecycle transitions', () { + test('startTask sets in_progress + started_at + a "started" log', () async { + final fake = _FakeClient('u1'); + final c = ProgrammerTasksController(fake as dynamic); + final id = await c.createTask( + title: 't', + category: ProgrammerTaskCategory.meeting, + ); + + await c.startTask(taskId: id); + + final row = _task(fake, id); + expect(row['status'], ProgrammerTaskStatus.inProgress); + expect(row['started_at'], isNotNull); + expect(_logs(fake).any((l) => l['action_type'] == 'started'), isTrue); + }); + + test('pauseTask is idempotent while already paused', () async { + final fake = _FakeClient('u1'); + final c = ProgrammerTasksController(fake as dynamic); + final id = await c.createTask( + title: 't', + category: ProgrammerTaskCategory.others, + ); + await c.startTask(taskId: id); + + await c.pauseTask(taskId: id); + await c.pauseTask(taskId: id); // second call is a no-op + + expect(_logs(fake).where((l) => l['action_type'] == 'paused').length, 1); + }); + + test('resumeTask logs a "resumed" event after a pause', () async { + final fake = _FakeClient('u1'); + final c = ProgrammerTasksController(fake as dynamic); + final id = await c.createTask( + title: 't', + category: ProgrammerTaskCategory.others, + ); + await c.startTask(taskId: id); + await c.pauseTask(taskId: id); + + await c.resumeTask(taskId: id); + + expect(_logs(fake).where((l) => l['action_type'] == 'resumed').length, 1); + }); + + test('completeTask sets completed status + timestamp + log', () async { + final fake = _FakeClient('u1'); + final c = ProgrammerTasksController(fake as dynamic); + final id = await c.createTask( + title: 't', + category: ProgrammerTaskCategory.others, + ); + await c.startTask(taskId: id); + + await c.completeTask(taskId: id); + + final row = _task(fake, id); + expect(row['status'], ProgrammerTaskStatus.completed); + expect(row['completed_at'], isNotNull); + expect(_logs(fake).any((l) => l['action_type'] == 'completed'), isTrue); + }); + + test('cancelTask stores the reason and a "cancelled" log', () async { + final fake = _FakeClient('u1'); + final c = ProgrammerTasksController(fake as dynamic); + final id = await c.createTask( + title: 't', + category: ProgrammerTaskCategory.others, + ); + + await c.cancelTask(taskId: id, reason: 'Descoped'); + + final row = _task(fake, id); + expect(row['status'], ProgrammerTaskStatus.cancelled); + expect(row['cancellation_reason'], 'Descoped'); + expect(_logs(fake).any((l) => l['action_type'] == 'cancelled'), isTrue); + }); + + test('reassign updates assignee and logs a "reassigned" event', () async { + final fake = _FakeClient('u1'); + final c = ProgrammerTasksController(fake as dynamic); + final id = await c.createTask( + title: 't', + category: ProgrammerTaskCategory.others, + ); + + await c.reassign(taskId: id, newAssigneeId: 'u2'); + + expect(_task(fake, id)['assignee_id'], 'u2'); + expect(_logs(fake).any((l) => l['action_type'] == 'reassigned'), isTrue); + }); + }); + + group('startProgrammerTaskWithPausePrompt (pause-on-switch UX)', () { + _FakeClient seededSwitchState() { + final fake = _FakeClient('u1'); + // Task A is actively running for u1. + fake.tables['programmer_tasks']!.add({ + 'id': 'A', + 'title': 'Task A', + 'category': ProgrammerTaskCategory.others, + 'assignee_id': 'u1', + 'status': ProgrammerTaskStatus.inProgress, + 'started_at': fake.nextTimestamp(), + 'created_at': fake.nextTimestamp(), + }); + _logs(fake).add({ + 'task_id': 'A', + 'action_type': 'started', + 'created_at': fake.nextTimestamp(), + }); + // Task B is queued and about to be started. + fake.tables['programmer_tasks']!.add({ + 'id': 'B', + 'title': 'Task B', + 'category': ProgrammerTaskCategory.others, + 'assignee_id': 'u1', + 'status': ProgrammerTaskStatus.queued, + 'created_at': fake.nextTimestamp(), + }); + return fake; + } + + ProgrammerTask taskB() => ProgrammerTask( + id: 'B', + title: 'Task B', + category: ProgrammerTaskCategory.others, + status: ProgrammerTaskStatus.queued, + assigneeId: 'u1', + creatorId: 'u1', + createdAt: DateTime.utc(2026, 1, 1), + updatedAt: DateTime.utc(2026, 1, 1), + ); + + Widget harness(_FakeClient fake) => ProviderScope( + overrides: [ + programmerTasksControllerProvider.overrideWithValue( + ProgrammerTasksController(fake as dynamic), + ), + ], + child: MaterialApp( + home: Scaffold( + body: Consumer( + builder: (context, ref, _) => ElevatedButton( + onPressed: () => + startProgrammerTaskWithPausePrompt(context, ref, taskB()), + child: const Text('Start B'), + ), + ), + ), + ), + ); + + testWidgets('prompts, then pauses A and starts B on confirm', (tester) async { + final fake = seededSwitchState(); + await tester.pumpWidget(harness(fake)); + + await tester.tap(find.text('Start B')); + await tester.pumpAndSettle(); + + // The prompt names the currently-running task. + expect(find.text('Pause current task?'), findsOneWidget); + + await tester.tap(find.text('Pause & Start')); + await tester.pumpAndSettle(); + + expect( + _logs(fake).any((l) => l['task_id'] == 'A' && l['action_type'] == 'paused'), + isTrue, + ); + expect(_task(fake, 'B')['status'], ProgrammerTaskStatus.inProgress); + }); + + testWidgets('cancel leaves both tasks untouched', (tester) async { + final fake = seededSwitchState(); + await tester.pumpWidget(harness(fake)); + + await tester.tap(find.text('Start B')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(_logs(fake).any((l) => l['action_type'] == 'paused'), isFalse); + expect(_task(fake, 'B')['status'], ProgrammerTaskStatus.queued); + }); + }); +}