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/pass_slip.model.dart';
import '../models/profile.model.dart';
import '../models/programmer_task.model.dart';
import '../models/programmer_task_activity_log.model.dart';
import '../models/service.model.dart';
import '../models/swap_request.model.dart';
import '../models/task.model.dart';
@@ -56,6 +58,8 @@ part 'adapters/notification_item_adapter.g.dart';
part 'adapters/office_adapter.g.dart';
part 'adapters/pass_slip_adapter.g.dart';
part 'adapters/profile_adapter.g.dart';
part 'adapters/programmer_task_adapter.g.dart';
part 'adapters/programmer_task_activity_log_adapter.g.dart';
part 'adapters/service_adapter.g.dart';
part 'adapters/swap_request_adapter.g.dart';
part 'adapters/task_adapter.g.dart';
@@ -79,6 +83,8 @@ final Map<Type, SupabaseAdapter<SupabaseModel>> supabaseMappings = {
Office: OfficeAdapter(),
PassSlip: PassSlipAdapter(),
Profile: ProfileAdapter(),
ProgrammerTask: ProgrammerTaskAdapter(),
ProgrammerTaskActivityLog: ProgrammerTaskActivityLogAdapter(),
Service: ServiceAdapter(),
SwapRequest: SwapRequestAdapter(),
Task: TaskAdapter(),
@@ -104,6 +110,8 @@ final Map<Type, SqliteAdapter<SqliteModel>> sqliteMappings = {
Office: OfficeAdapter(),
PassSlip: PassSlipAdapter(),
Profile: ProfileAdapter(),
ProgrammerTask: ProgrammerTaskAdapter(),
ProgrammerTaskActivityLog: ProgrammerTaskActivityLogAdapter(),
Service: ServiceAdapter(),
SwapRequest: SwapRequestAdapter(),
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
// This file should be version controlled
import 'package:brick_sqlite/db.dart';
part '20260926162926.migration.dart';
part '20260411041808.migration.dart';
part '20260411042152.migration.dart';
/// All intelligently-generated migrations from all `@Migratable` classes on disk
final migrations = <Migration>{
const Migration20260926162926(),
const Migration20260411041808(),
const Migration20260411042152(),
};
@@ -334,6 +336,53 @@ final schema = Schema(
},
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(
'Service',
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/it_service_requests/it_service_requests_list_screen.dart';
import '../screens/it_service_requests/it_service_request_detail_screen.dart';
import '../screens/programmer_tasks/programmer_tasks_list_screen.dart';
import '../screens/programmer_tasks/programmer_task_detail_screen.dart';
import '../screens/network_map/network_map_overview_screen.dart';
import '../screens/network_map/network_map_site_screen.dart';
import '../screens/network_map/network_map_device_screen.dart';
@@ -93,6 +95,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
role == 'it_staff' ||
role == 'dispatcher' ||
role == 'programmer';
final isProgrammerTasksRoute =
state.matchedLocation.startsWith('/programmer-tasks');
final hasProgrammerTasksAccess =
role == 'admin' || role == 'programmer';
if (!isSignedIn && !isAuthRoute) {
return '/login';
@@ -125,6 +131,9 @@ final appRouterProvider = Provider<GoRouter>((ref) {
if (isNetworkMapRoute && !hasNetworkMapAccess) {
return '/tickets';
}
if (isProgrammerTasksRoute && !hasProgrammerTasksAccess) {
return '/tickets';
}
// Attendance & Whereabouts: not accessible to standard users
final isStandardOnly = role == 'standard';
final isAttendanceRoute =
@@ -207,6 +216,21 @@ final appRouterProvider = Provider<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(
path: '/announcements',
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';
/// Finds the canonical execution start time for a task.
/// A minimal activity event shape: just the fields the worked-duration math
/// needs. Both [TaskActivityLog] and ProgrammerTaskActivityLog map onto this,
/// so the pause-aware duration logic is shared across task modules.
typedef DurationEvent = ({DateTime createdAt, String actionType});
// ---------------------------------------------------------------------------
// Generic core (works on any DurationEvent list)
// ---------------------------------------------------------------------------
/// Finds the canonical execution start time from [events].
///
/// [logs] is expected in DESCENDING order (newest first), matching the
/// `order('created_at', ascending: false)` used by Supabase queries.
/// The reversed iteration therefore walks chronologically forward and stops
/// at the earliest 'started' event.
DateTime? resolveTaskExecutionStart(
List<TaskActivityLog> logs,
/// [events] is expected in DESCENDING order (newest first). The reversed
/// iteration walks chronologically forward and stops at the earliest 'started'
/// event, falling back to [fallbackStartedAt].
DateTime? resolveExecutionStart(
List<DurationEvent> events,
DateTime? fallbackStartedAt,
) {
DateTime? started;
for (final entry in logs.reversed) {
for (final entry in events.reversed) {
if (entry.actionType == 'started') {
started = entry.createdAt;
break;
@@ -20,31 +28,30 @@ DateTime? resolveTaskExecutionStart(
return started ?? fallbackStartedAt;
}
/// Computes the effective worked duration for a task, subtracting all
/// paused intervals between [fallbackStartedAt] (or the first 'started'
/// log event) and [endAt].
/// Computes the effective worked duration, subtracting all paused intervals
/// between the resolved start and [endAt].
///
/// [logs] must be in DESCENDING order (newest first) — the same order
/// returned by `task_activity_logs` queries with `ascending: false`.
Duration computeEffectiveTaskDuration({
/// [events] must be in DESCENDING order (newest first) — the same order
/// returned by activity-log queries with `ascending: false`.
Duration computeEffectiveDuration({
required DateTime? fallbackStartedAt,
required DateTime endAt,
required List<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;
Duration pausedTotal = Duration.zero;
DateTime? pausedSince;
// logs.reversed → ascending (chronological) order within [start, endAt]
final events = logs.reversed.where((e) {
// events.reversed → ascending (chronological) order within [start, endAt]
final relevant = events.reversed.where((e) {
if (e.createdAt.isBefore(start)) return false;
if (e.createdAt.isAfter(endAt)) return false;
return e.actionType == 'paused' || e.actionType == 'resumed';
});
for (final event in events) {
for (final event in relevant) {
if (event.actionType == 'paused') {
pausedSince ??= event.createdAt;
} else if (event.actionType == 'resumed' && pausedSince != null) {
@@ -55,7 +62,7 @@ Duration computeEffectiveTaskDuration({
}
}
// Task is still paused at endAt — count the open pause interval.
// Still paused at endAt — count the open pause interval.
if (pausedSince != null && endAt.isAfter(pausedSince)) {
pausedTotal += endAt.difference(pausedSince);
}
@@ -63,3 +70,29 @@ Duration computeEffectiveTaskDuration({
final total = endAt.difference(start) - pausedTotal;
return total.isNegative ? Duration.zero : total;
}
// ---------------------------------------------------------------------------
// TaskActivityLog wrappers (unchanged public API — existing call sites intact)
// ---------------------------------------------------------------------------
List<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,
selectedIcon: Icons.task,
),
if (role == 'admin' || role == 'programmer')
NavItem(
label: 'Programmer Tasks',
route: '/programmer-tasks',
icon: Icons.developer_mode_outlined,
selectedIcon: Icons.developer_mode,
),
NavItem(
label: 'IT Service Requests',
route: '/it-service-requests',
@@ -715,6 +722,7 @@ String _routeToTitle(String location) {
'/attendance' => 'Attendance',
'/tickets' => 'Tickets',
'/tasks' => 'Tasks',
'/programmer-tasks' => 'Programmer Tasks',
'/it-service-requests' => 'IT Service Requests',
'/network-map' => 'Network Map',
'/notifications' => 'Notifications',