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:
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>{
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user