405 lines
15 KiB
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!),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|