Files
2026-09-28 14:08:28 +08:00

203 lines
7.1 KiB
Dart

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.
///
/// When [workDate] is supplied the entry is a correction for a past day:
/// the header names that date and the pause/deduct prompts are skipped.
class WorkLogSection extends ConsumerStatefulWidget {
const WorkLogSection({super.key, required this.task, required this.isAssignee, this.workDate});
final ProgrammerTask task;
final bool isAssignee;
final DateTime? workDate;
@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,
workDate: widget.workDate,
);
_descQuill.clear();
_hoursCtrl.clear();
_minutesCtrl.clear();
if (!mounted) return;
if (widget.workDate == null) {
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: [
AppSectionHeader(
widget.workDate != null
? 'Correction for ${AppTime.formatDate(widget.workDate!)}'
: '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),
],
),
),
],
);
}
}