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