uto_manager/lib/presentation/screens/dialogs/add_edit_personnel_dialog.dart

405 lines
15 KiB
Dart

import 'package:flutter/material.dart';
import 'package:uto_manager/domain/entities/authorized_rank.dart';
import 'package:uto_manager/domain/entities/personnel_record.dart';
import 'package:uto_manager/domain/entities/soldier_position.dart';
import 'package:uto_manager/domain/value_objects/duty_mos.dart';
import 'package:uto_manager/infrastructure/repositories/drift_soldier_positions_repository.dart';
import 'package:uuid/uuid.dart';
/// Dialog for creating or editing a [PersonnelRecord].
///
/// Pass [existing] to edit; leave null to create a new record.
/// [positionsRepository] is used to load and manage the position list.
class AddEditPersonnelDialog extends StatefulWidget {
const AddEditPersonnelDialog({
required this.positionsRepository,
super.key,
this.existing,
});
final DriftSoldierPositionsRepository positionsRepository;
final PersonnelRecord? existing;
@override
State<AddEditPersonnelDialog> createState() => _AddEditPersonnelDialogState();
}
class _AddEditPersonnelDialogState extends State<AddEditPersonnelDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameCtrl;
late final TextEditingController _mosCtrl;
late AuthorizedRank _payGrade;
late ReadinessState _readiness;
/// The currently selected position, or null if none chosen.
SoldierPosition? _selectedPosition;
/// All positions loaded from the repository.
List<SoldierPosition> _positions = [];
List<String> _mosSuggestions = [];
bool get _isEditing => widget.existing != null;
@override
void initState() {
super.initState();
final e = widget.existing;
_nameCtrl = TextEditingController(text: e?.name ?? '');
_mosCtrl = TextEditingController(text: e?.primaryMos.code ?? '');
_payGrade = e?.payGrade ?? AuthorizedRank.e4;
_readiness = e?.readinessState ?? ReadinessState.fullyMissionCapable;
_loadPositions(existingTitle: e?.positionTitle);
}
@override
void dispose() {
_nameCtrl.dispose();
_mosCtrl.dispose();
super.dispose();
}
Future<void> _loadPositions({String? existingTitle}) async {
await widget.positionsRepository.seedBuiltIns();
final positions = await widget.positionsRepository.getAll();
positions.sort((a, b) => a.title.compareTo(b.title));
final selected =
existingTitle != null ? positions.where((p) => p.title == existingTitle).firstOrNull : null;
if (mounted) {
setState(() {
_positions = positions;
_selectedPosition = selected;
});
}
}
void _updateMosSuggestions(String query) {
if (query.isEmpty) {
setState(() => _mosSuggestions = []);
return;
}
final q = query.toUpperCase();
final matches =
DutyMos.allCodes.where((c) => c.startsWith(q) || c.contains(q)).take(12).toList()..sort();
setState(() => _mosSuggestions = matches);
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
final mosCode = _mosCtrl.text.trim().toUpperCase();
DutyMos mos;
try {
mos = DutyMos(mosCode);
} catch (_) {
mos = DutyMos.unvalidated(mosCode);
}
final nameText = _nameCtrl.text.trim();
final record = PersonnelRecord(
soldierId: widget.existing?.soldierId ?? const Uuid().v4(),
name: nameText.isEmpty ? null : nameText,
positionTitle: _selectedPosition?.title,
primaryMos: mos,
payGrade: _payGrade,
readinessState: _readiness,
qualifications: widget.existing?.qualifications ?? const [],
assignedSlotId: widget.existing?.assignedSlotId,
);
Navigator.of(context).pop(record);
}
// ---------------------------------------------------------------------------
// Position CRUD helpers
// ---------------------------------------------------------------------------
Future<void> _showAddPositionDialog() async {
final title = await _showPositionTitleDialog(context, initial: '');
if (title == null || title.isEmpty) return;
final pos = SoldierPosition(positionId: const Uuid().v4(), title: title);
await widget.positionsRepository.upsert(pos);
await _loadPositions();
if (mounted) {
setState(() => _selectedPosition = _positions.where((p) => p.title == title).firstOrNull);
}
}
Future<void> _showEditPositionDialog(SoldierPosition pos) async {
if (pos.isBuiltIn) return; // built-ins cannot be renamed
final title = await _showPositionTitleDialog(context, initial: pos.title);
if (title == null || title.isEmpty || title == pos.title) return;
await widget.positionsRepository.upsert(pos.copyWith(title: title));
await _loadPositions();
if (mounted) {
setState(
() =>
_selectedPosition = _positions.where((p) => p.positionId == pos.positionId).firstOrNull,
);
}
}
Future<void> _confirmDeletePosition(SoldierPosition pos) async {
if (pos.isBuiltIn) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Position?'),
content: Text('Remove the "${pos.title}" position? This cannot be undone.'),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Cancel')),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Delete'),
),
],
),
);
if (confirmed ?? false) {
await widget.positionsRepository.delete(pos.positionId);
await _loadPositions();
if (mounted && _selectedPosition?.positionId == pos.positionId) {
setState(() => _selectedPosition = null);
}
}
}
/// Shows a simple text-input dialog for a position title.
Future<String?> _showPositionTitleDialog(BuildContext context, {required String initial}) {
final ctrl = TextEditingController(text: initial);
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(initial.isEmpty ? 'Add Position' : 'Rename Position'),
content: TextField(
controller: ctrl,
autofocus: true,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Position Title',
hintText: 'e.g. Anti-Tank Gunner',
),
onSubmitted: (_) => Navigator.of(ctx).pop(ctrl.text.trim()),
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('Cancel')),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(ctrl.text.trim()),
child: const Text('Save'),
),
],
),
);
}
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
title: Row(
children: [
Icon(_isEditing ? Icons.edit : Icons.person_add, size: 20),
const SizedBox(width: 8),
Text(_isEditing ? 'Edit Soldier' : 'Add Soldier'),
],
),
content: SizedBox(
width: 420,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Position ─────────────────────────────────────────────────
_buildPositionRow(theme),
const SizedBox(height: 12),
// ── Name (optional) ──────────────────────────────────────────
TextFormField(
controller: _nameCtrl,
decoration: const InputDecoration(
labelText: 'Full Name (optional)',
hintText: 'e.g. SMITH, John A.',
prefixIcon: Icon(Icons.badge_outlined),
helperText: 'Leave blank to identify by position only.',
),
textCapitalization: TextCapitalization.characters,
),
const SizedBox(height: 12),
// ── Pay Grade ────────────────────────────────────────────────
DropdownButtonFormField<AuthorizedRank>(
initialValue: _payGrade,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Pay Grade',
prefixIcon: Icon(Icons.military_tech_outlined),
),
items: AuthorizedRank.values.map((r) {
return DropdownMenuItem(
value: r,
child: Text('${r.payGrade} — ${r.rankTitle}'),
);
}).toList(),
onChanged: (v) => setState(() => _payGrade = v!),
),
const SizedBox(height: 12),
// ── MOS with autocomplete ────────────────────────────────────
TextFormField(
controller: _mosCtrl,
decoration: const InputDecoration(
labelText: 'Primary MOS',
hintText: 'e.g. 11B, 68W, 25U',
prefixIcon: Icon(Icons.work_outline),
),
textCapitalization: TextCapitalization.characters,
onChanged: _updateMosSuggestions,
validator: (v) {
if (v == null || v.trim().isEmpty) return 'MOS is required';
final code = v.trim().toUpperCase();
if (!DutyMos.isKnown(code)) {
return 'Unknown MOS code. Use a valid Army MOS (e.g. 11B, 68W)';
}
return null;
},
),
if (_mosSuggestions.isNotEmpty) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Wrap(
spacing: 4,
runSpacing: 4,
children: _mosSuggestions.map((code) {
return ActionChip(
label: Text(
code,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
onPressed: () {
_mosCtrl.text = code;
setState(() => _mosSuggestions = []);
},
padding: const EdgeInsets.symmetric(horizontal: 4),
visualDensity: VisualDensity.compact,
);
}).toList(),
),
),
],
const SizedBox(height: 12),
// ── Readiness State ──────────────────────────────────────────
DropdownButtonFormField<ReadinessState>(
initialValue: _readiness,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Readiness State',
prefixIcon: Icon(Icons.health_and_safety_outlined),
),
items: ReadinessState.values.map((s) {
return DropdownMenuItem(
value: s,
child: Text(s.displayName),
);
}).toList(),
onChanged: (v) => setState(() => _readiness = v!),
),
],
),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton.icon(
icon: Icon(_isEditing ? Icons.save : Icons.add, size: 16),
label: Text(_isEditing ? 'Save Changes' : 'Add Soldier'),
onPressed: _submit,
),
],
);
}
/// Builds the position dropdown row with add / edit / delete action buttons.
Widget _buildPositionRow(ThemeData theme) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: _positions.isEmpty
? const LinearProgressIndicator()
: DropdownButtonFormField<SoldierPosition>(
initialValue: _selectedPosition,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Position',
prefixIcon: Icon(Icons.work_history_outlined),
),
hint: const Text('Select position…'),
items: _positions.map((p) {
return DropdownMenuItem(
value: p,
child: Row(
children: [
Expanded(child: Text(p.title, overflow: TextOverflow.ellipsis)),
if (p.isBuiltIn)
Icon(
Icons.lock_outline,
size: 12,
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
],
),
);
}).toList(),
onChanged: (v) => setState(() => _selectedPosition = v),
),
),
// Add custom position
IconButton(
icon: const Icon(Icons.add_circle_outline, size: 18),
tooltip: 'Add custom position',
onPressed: _showAddPositionDialog,
),
// Edit selected position (only if custom)
if (_selectedPosition != null && !_selectedPosition!.isBuiltIn)
IconButton(
icon: const Icon(Icons.edit_outlined, size: 18),
tooltip: 'Rename position',
onPressed: () => _showEditPositionDialog(_selectedPosition!),
),
// Delete selected position (only if custom)
if (_selectedPosition != null && !_selectedPosition!.isBuiltIn)
IconButton(
icon: Icon(Icons.delete_outline, size: 18, color: theme.colorScheme.error),
tooltip: 'Delete position',
onPressed: () => _confirmDeletePosition(_selectedPosition!),
),
],
);
}
}