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 <ruv@ruv.net>
This commit is contained in:
2026-09-27 01:13:33 +08:00
parent e7706058b7
commit 627645056b
18 changed files with 3214 additions and 20 deletions
@@ -0,0 +1,197 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<ProgrammerTaskActivityLog> _$ProgrammerTaskActivityLogFromSupabase(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<ProgrammerTaskActivityLog> _$ProgrammerTaskActivityLogFromSqlite(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<ProgrammerTaskActivityLog> {
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<String, RuntimeSqliteColumnDefinition> 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<int?> primaryKeyByUniqueColumns(
ProgrammerTaskActivityLog instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'ProgrammerTaskActivityLog';
@override
Future<ProgrammerTaskActivityLog> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProgrammerTaskActivityLogFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
ProgrammerTaskActivityLog input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProgrammerTaskActivityLogToSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<ProgrammerTaskActivityLog> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProgrammerTaskActivityLogFromSqlite(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSqlite(
ProgrammerTaskActivityLog input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProgrammerTaskActivityLogToSqlite(
input,
provider: provider,
repository: repository,
);
}
@@ -0,0 +1,361 @@
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<ProgrammerTask> _$ProgrammerTaskFromSupabase(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<ProgrammerTask> _$ProgrammerTaskFromSqlite(
Map<String, dynamic> 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<Map<String, dynamic>> _$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<ProgrammerTask> {
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<String, RuntimeSqliteColumnDefinition> 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<int?> primaryKeyByUniqueColumns(
ProgrammerTask instance,
DatabaseExecutor executor,
) async => instance.primaryKey;
@override
final String tableName = 'ProgrammerTask';
@override
Future<ProgrammerTask> fromSupabase(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProgrammerTaskFromSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSupabase(
ProgrammerTask input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProgrammerTaskToSupabase(
input,
provider: provider,
repository: repository,
);
@override
Future<ProgrammerTask> fromSqlite(
Map<String, dynamic> input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProgrammerTaskFromSqlite(
input,
provider: provider,
repository: repository,
);
@override
Future<Map<String, dynamic>> toSqlite(
ProgrammerTask input, {
required provider,
covariant OfflineFirstWithSupabaseRepository? repository,
}) async => await _$ProgrammerTaskToSqlite(
input,
provider: provider,
repository: repository,
);
}
+8
View File
@@ -35,6 +35,8 @@ import '../models/notification_item.model.dart';
import '../models/office.model.dart'; import '../models/office.model.dart';
import '../models/pass_slip.model.dart'; import '../models/pass_slip.model.dart';
import '../models/profile.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/service.model.dart';
import '../models/swap_request.model.dart'; import '../models/swap_request.model.dart';
import '../models/task.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/office_adapter.g.dart';
part 'adapters/pass_slip_adapter.g.dart'; part 'adapters/pass_slip_adapter.g.dart';
part 'adapters/profile_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/service_adapter.g.dart';
part 'adapters/swap_request_adapter.g.dart'; part 'adapters/swap_request_adapter.g.dart';
part 'adapters/task_adapter.g.dart'; part 'adapters/task_adapter.g.dart';
@@ -79,6 +83,8 @@ final Map<Type, SupabaseAdapter<SupabaseModel>> supabaseMappings = {
Office: OfficeAdapter(), Office: OfficeAdapter(),
PassSlip: PassSlipAdapter(), PassSlip: PassSlipAdapter(),
Profile: ProfileAdapter(), Profile: ProfileAdapter(),
ProgrammerTask: ProgrammerTaskAdapter(),
ProgrammerTaskActivityLog: ProgrammerTaskActivityLogAdapter(),
Service: ServiceAdapter(), Service: ServiceAdapter(),
SwapRequest: SwapRequestAdapter(), SwapRequest: SwapRequestAdapter(),
Task: TaskAdapter(), Task: TaskAdapter(),
@@ -104,6 +110,8 @@ final Map<Type, SqliteAdapter<SqliteModel>> sqliteMappings = {
Office: OfficeAdapter(), Office: OfficeAdapter(),
PassSlip: PassSlipAdapter(), PassSlip: PassSlipAdapter(),
Profile: ProfileAdapter(), Profile: ProfileAdapter(),
ProgrammerTask: ProgrammerTaskAdapter(),
ProgrammerTaskActivityLog: ProgrammerTaskActivityLogAdapter(),
Service: ServiceAdapter(), Service: ServiceAdapter(),
SwapRequest: SwapRequestAdapter(), SwapRequest: SwapRequestAdapter(),
Task: TaskAdapter(), Task: TaskAdapter(),
@@ -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<MigrationCommand> _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<MigrationCommand> _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,
);
}
+49
View File
@@ -1,11 +1,13 @@
// GENERATED CODE DO NOT EDIT // GENERATED CODE DO NOT EDIT
// This file should be version controlled // This file should be version controlled
import 'package:brick_sqlite/db.dart'; import 'package:brick_sqlite/db.dart';
part '20260926162926.migration.dart';
part '20260411041808.migration.dart'; part '20260411041808.migration.dart';
part '20260411042152.migration.dart'; part '20260411042152.migration.dart';
/// All intelligently-generated migrations from all `@Migratable` classes on disk /// All intelligently-generated migrations from all `@Migratable` classes on disk
final migrations = <Migration>{ final migrations = <Migration>{
const Migration20260926162926(),
const Migration20260411041808(), const Migration20260411041808(),
const Migration20260411042152(), const Migration20260411042152(),
}; };
@@ -334,6 +336,53 @@ final schema = Schema(
}, },
indices: <SchemaIndex>{}, indices: <SchemaIndex>{},
), ),
SchemaTable(
'ProgrammerTask',
columns: <SchemaColumn>{
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: <SchemaIndex>{},
),
SchemaTable(
'ProgrammerTaskActivityLog',
columns: <SchemaColumn>{
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: <SchemaIndex>{},
),
SchemaTable( SchemaTable(
'Service', 'Service',
columns: <SchemaColumn>{ columns: <SchemaColumn>{
+145
View File
@@ -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<String, dynamic> 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),
);
}
}
@@ -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<String, dynamic>? 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<String, dynamic> 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<String, dynamic>? meta;
final rawMeta = map['meta'];
if (rawMeta is Map<String, dynamic>) {
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,
);
}
}
@@ -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<ProgrammerTask> 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<List<ProgrammerTask>>((ref) => const []);
final offlinePendingProgrammerTaskRawProvider =
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
final offlinePendingProgrammerTaskUpdatesProvider =
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
final offlinePendingProgrammerTaskLogsProvider =
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
// ---------------------------------------------------------------------------
// Stream providers
// ---------------------------------------------------------------------------
final programmerTasksProvider = StreamProvider<List<ProgrammerTask>>((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<ProgrammerTask> applyPending(List<ProgrammerTask> 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<ProgrammerTask>(
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<ProgrammerTask>();
return applyPending(all);
},
onCacheMirror: (rows) =>
mirrorBatchToBrick<ProgrammerTask>(rows, tag: 'programmer_tasks'),
);
ref.onDispose(wrapper.dispose);
// Reconnect-replay: POST queued items directly, bypassing Brick's queue.
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
if (wasOnline == true || !isNowOnline) return;
// 1. Replay offline-created tasks.
final pendingRaw = List<Map<String, dynamic>>.from(
ref.read(offlinePendingProgrammerTaskRawProvider),
);
if (pendingRaw.isNotEmpty) {
final syncedIds = <String>[];
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<String, Map<String, dynamic>>.from(
ref.read(offlinePendingProgrammerTaskUpdatesProvider),
);
if (pendingUpdatesMap.isNotEmpty) {
final syncedIds = <String>[];
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<String, Map<String, dynamic>>.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<Map<String, dynamic>>.from(
ref.read(offlinePendingProgrammerTaskLogsProvider),
);
if (pendingLogs.isNotEmpty) {
final synced = <Map<String, dynamic>>[];
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<ProgrammerTask?, String>((
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<String?>((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<List<ProgrammerTaskActivityLog>, String>((ref, taskId) {
final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<ProgrammerTaskActivityLog>(
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<ProgrammerTaskActivityLog>();
return all.where((l) => l.taskId == taskId).toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
},
onCacheMirror: (rows) => mirrorBatchToBrick<ProgrammerTaskActivityLog>(
rows,
tag: 'programmer_task_activity_logs',
),
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
});
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
final programmerTasksControllerProvider = Provider<ProgrammerTasksController>((
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<bool> _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<void> _insertLog(
String taskId,
String actionType, {
Map<String, dynamic>? meta,
}) async {
final row = <String, dynamic>{
'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<String> 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 = <String, dynamic>{
'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<ProgrammerTask?> 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<String, dynamic>);
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<void> startTask({required String taskId}) async {
final updates = <String, dynamic>{
'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<void> 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<void> resumeTask({required String taskId}) async {
if (_isOnline && !await _isCurrentlyPaused(taskId)) return;
await _insertLog(taskId, 'resumed');
}
Future<void> completeTask({required String taskId}) async {
await _updateTaskRow(taskId, {
'status': ProgrammerTaskStatus.completed,
'completed_at': DateTime.now().toUtc().toIso8601String(),
});
await _insertLog(taskId, 'completed');
}
Future<void> 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<void> reassign({
required String taskId,
required String newAssigneeId,
}) async {
await _updateTaskRow(taskId, {'assignee_id': newAssigneeId});
await _insertLog(taskId, 'reassigned', meta: {'assignee_id': newAssigneeId});
}
Future<void> updateTask({
required String taskId,
String? title,
String? description,
String? category,
int? priority,
}) async {
final updates = <String, dynamic>{};
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<void> _updateTaskRow(
String taskId,
Map<String, dynamic> 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<String, dynamic>.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<String, dynamic> 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<ProgrammerTask>([model], tag: 'offline_programmer_task');
}
void _queueUpdate(String taskId, Map<String, dynamic> fields) {
final ref = _ref;
if (ref == null) return;
final current = Map<String, Map<String, dynamic>>.from(
ref.read(offlinePendingProgrammerTaskUpdatesProvider),
);
current[taskId] = {...?current[taskId], ...fields};
ref.read(offlinePendingProgrammerTaskUpdatesProvider.notifier).state =
current;
}
void _queueLog(Map<String, dynamic> 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<String, dynamic> 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(),
);
}
+24
View File
@@ -30,6 +30,8 @@ import '../widgets/app_shell.dart';
import '../screens/teams/teams_screen.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_requests_list_screen.dart';
import '../screens/it_service_requests/it_service_request_detail_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_overview_screen.dart';
import '../screens/network_map/network_map_site_screen.dart'; import '../screens/network_map/network_map_site_screen.dart';
import '../screens/network_map/network_map_device_screen.dart'; import '../screens/network_map/network_map_device_screen.dart';
@@ -93,6 +95,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
role == 'it_staff' || role == 'it_staff' ||
role == 'dispatcher' || role == 'dispatcher' ||
role == 'programmer'; role == 'programmer';
final isProgrammerTasksRoute =
state.matchedLocation.startsWith('/programmer-tasks');
final hasProgrammerTasksAccess =
role == 'admin' || role == 'programmer';
if (!isSignedIn && !isAuthRoute) { if (!isSignedIn && !isAuthRoute) {
return '/login'; return '/login';
@@ -125,6 +131,9 @@ final appRouterProvider = Provider<GoRouter>((ref) {
if (isNetworkMapRoute && !hasNetworkMapAccess) { if (isNetworkMapRoute && !hasNetworkMapAccess) {
return '/tickets'; return '/tickets';
} }
if (isProgrammerTasksRoute && !hasProgrammerTasksAccess) {
return '/tickets';
}
// Attendance & Whereabouts: not accessible to standard users // Attendance & Whereabouts: not accessible to standard users
final isStandardOnly = role == 'standard'; final isStandardOnly = role == 'standard';
final isAttendanceRoute = final isAttendanceRoute =
@@ -207,6 +216,21 @@ final appRouterProvider = Provider<GoRouter>((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( GoRoute(
path: '/announcements', path: '/announcements',
pageBuilder: _page(const AnnouncementsScreen()), pageBuilder: _page(const AnnouncementsScreen()),
@@ -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<bool> 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<bool>(
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;
}
}
@@ -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 <Profile>[];
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<ProgrammerTaskActivityLog> logs;
final Map<String, Profile> 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<String, Profile> 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<void> _complete(
BuildContext context,
WidgetRef ref,
ProgrammerTask task,
) async {
final messenger = ScaffoldMessenger.of(context);
final ok = await showDialog<bool>(
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<void> _cancel(
BuildContext context,
WidgetRef ref,
ProgrammerTask task,
) async {
final reasonCtrl = TextEditingController();
final reason = await showDialog<String>(
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<void> _reassign(
BuildContext context,
WidgetRef ref,
ProgrammerTask task,
List<Profile> 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<String>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
semanticLabel: 'Reassign task',
title: const Text('Reassign task'),
content: DropdownButtonFormField<String>(
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')));
}
@@ -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,
};
@@ -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<ProgrammerTasksListScreen> createState() =>
_ProgrammerTasksListScreenState();
}
class _ProgrammerTasksListScreenState
extends ConsumerState<ProgrammerTasksListScreen>
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 = <String, Profile>{
for (final p in profilesAsync.valueOrNull ?? <Profile>[]) 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<void> _showCreateDialog(BuildContext context) async {
final userId = ref.read(currentUserIdProvider);
final profiles = ref.read(profilesProvider).valueOrNull ?? const <Profile>[];
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<bool>(
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<String>(
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<String>(
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'),
),
),
],
],
),
),
);
}
}
+53 -20
View File
@@ -1,17 +1,25 @@
import '../models/task_activity_log.model.dart'; 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 /// [events] is expected in DESCENDING order (newest first). The reversed
/// `order('created_at', ascending: false)` used by Supabase queries. /// iteration walks chronologically forward and stops at the earliest 'started'
/// The reversed iteration therefore walks chronologically forward and stops /// event, falling back to [fallbackStartedAt].
/// at the earliest 'started' event. DateTime? resolveExecutionStart(
DateTime? resolveTaskExecutionStart( List<DurationEvent> events,
List<TaskActivityLog> logs,
DateTime? fallbackStartedAt, DateTime? fallbackStartedAt,
) { ) {
DateTime? started; DateTime? started;
for (final entry in logs.reversed) { for (final entry in events.reversed) {
if (entry.actionType == 'started') { if (entry.actionType == 'started') {
started = entry.createdAt; started = entry.createdAt;
break; break;
@@ -20,31 +28,30 @@ DateTime? resolveTaskExecutionStart(
return started ?? fallbackStartedAt; return started ?? fallbackStartedAt;
} }
/// Computes the effective worked duration for a task, subtracting all /// Computes the effective worked duration, subtracting all paused intervals
/// paused intervals between [fallbackStartedAt] (or the first 'started' /// between the resolved start and [endAt].
/// log event) and [endAt].
/// ///
/// [logs] must be in DESCENDING order (newest first) — the same order /// [events] must be in DESCENDING order (newest first) — the same order
/// returned by `task_activity_logs` queries with `ascending: false`. /// returned by activity-log queries with `ascending: false`.
Duration computeEffectiveTaskDuration({ Duration computeEffectiveDuration({
required DateTime? fallbackStartedAt, required DateTime? fallbackStartedAt,
required DateTime endAt, required DateTime endAt,
required List<TaskActivityLog> logs, required List<DurationEvent> events,
}) { }) {
final start = resolveTaskExecutionStart(logs, fallbackStartedAt); final start = resolveExecutionStart(events, fallbackStartedAt);
if (start == null || !endAt.isAfter(start)) return Duration.zero; if (start == null || !endAt.isAfter(start)) return Duration.zero;
Duration pausedTotal = Duration.zero; Duration pausedTotal = Duration.zero;
DateTime? pausedSince; DateTime? pausedSince;
// logs.reversed → ascending (chronological) order within [start, endAt] // events.reversed → ascending (chronological) order within [start, endAt]
final events = logs.reversed.where((e) { final relevant = events.reversed.where((e) {
if (e.createdAt.isBefore(start)) return false; if (e.createdAt.isBefore(start)) return false;
if (e.createdAt.isAfter(endAt)) return false; if (e.createdAt.isAfter(endAt)) return false;
return e.actionType == 'paused' || e.actionType == 'resumed'; return e.actionType == 'paused' || e.actionType == 'resumed';
}); });
for (final event in events) { for (final event in relevant) {
if (event.actionType == 'paused') { if (event.actionType == 'paused') {
pausedSince ??= event.createdAt; pausedSince ??= event.createdAt;
} else if (event.actionType == 'resumed' && pausedSince != null) { } 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)) { if (pausedSince != null && endAt.isAfter(pausedSince)) {
pausedTotal += endAt.difference(pausedSince); pausedTotal += endAt.difference(pausedSince);
} }
@@ -63,3 +70,29 @@ Duration computeEffectiveTaskDuration({
final total = endAt.difference(start) - pausedTotal; final total = endAt.difference(start) - pausedTotal;
return total.isNegative ? Duration.zero : total; return total.isNegative ? Duration.zero : total;
} }
// ---------------------------------------------------------------------------
// TaskActivityLog wrappers (unchanged public API — existing call sites intact)
// ---------------------------------------------------------------------------
List<DurationEvent> _toEvents(List<TaskActivityLog> 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<TaskActivityLog> 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<TaskActivityLog> logs,
}) => computeEffectiveDuration(
fallbackStartedAt: fallbackStartedAt,
endAt: endAt,
events: _toEvents(logs),
);
+8
View File
@@ -460,6 +460,13 @@ List<NavSection> _buildSections(String role) {
icon: Icons.task_outlined, icon: Icons.task_outlined,
selectedIcon: Icons.task, 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( NavItem(
label: 'IT Service Requests', label: 'IT Service Requests',
route: '/it-service-requests', route: '/it-service-requests',
@@ -715,6 +722,7 @@ String _routeToTitle(String location) {
'/attendance' => 'Attendance', '/attendance' => 'Attendance',
'/tickets' => 'Tickets', '/tickets' => 'Tickets',
'/tasks' => 'Tasks', '/tasks' => 'Tasks',
'/programmer-tasks' => 'Programmer Tasks',
'/it-service-requests' => 'IT Service Requests', '/it-service-requests' => 'IT Service Requests',
'/network-map' => 'Network Map', '/network-map' => 'Network Map',
'/notifications' => 'Notifications', '/notifications' => 'Notifications',
@@ -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;
+85
View File
@@ -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);
});
});
}
+462
View File
@@ -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<String, List<Map<String, dynamic>>> 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<List<Map<String, dynamic>>> {
_FakeQuery(this.client, this.table);
final _FakeClient client;
final String table;
final List<MapEntry<String, dynamic>> _eqs = [];
String? _inField;
List<String>? _inValues;
String? _orderField;
bool _orderAsc = true;
int? _limit;
Map<String, dynamic>? _insertPayload;
Map<String, dynamic>? _updatePayload;
List<Map<String, dynamic>> get _rows {
var rows = List<Map<String, dynamic>>.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<List<Map<String, dynamic>>> get _future async {
if (_insertPayload != null) {
final row = Map<String, dynamic>.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<String, dynamic> payload) {
_insertPayload = Map<String, dynamic>.from(payload);
return this;
}
_FakeQuery update(Map<String, dynamic> payload) {
_updatePayload = Map<String, dynamic>.from(payload);
return this;
}
_FakeQuery inFilter(String field, List<dynamic> 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<Map<String, dynamic>?> maybeSingle() async {
final rows = _rows;
return rows.isEmpty ? null : Map<String, dynamic>.from(rows.first);
}
// Future delegation.
@override
Stream<List<Map<String, dynamic>>> asStream() => _future.asStream();
@override
Future<List<Map<String, dynamic>>> catchError(Function onError,
{bool Function(Object error)? test}) =>
_future.catchError(onError, test: test);
@override
Future<R> then<R>(FutureOr<R> Function(List<Map<String, dynamic>>) onValue,
{Function? onError}) =>
_future.then(onValue, onError: onError);
@override
Future<List<Map<String, dynamic>>> timeout(Duration timeLimit,
{FutureOr<List<Map<String, dynamic>>> Function()? onTimeout}) =>
_future.timeout(timeLimit, onTimeout: onTimeout);
@override
Future<List<Map<String, dynamic>>> whenComplete(FutureOr<void> Function() a) =>
_future.whenComplete(a);
}
List<Map<String, dynamic>> _logs(_FakeClient c) =>
c.tables['programmer_task_activity_logs']!;
Map<String, dynamic> _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);
});
});
}