718 lines
24 KiB
Dart
718 lines
24 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
import '../../../core/theme/app_theme.dart';
|
||
import '../../../models/job.dart';
|
||
import '../../../models/patient.dart';
|
||
import '../jobs/clinic_jobs_repository.dart';
|
||
import 'clinic_patients_repository.dart';
|
||
|
||
class ClinicPatientDetailScreen extends ConsumerStatefulWidget {
|
||
const ClinicPatientDetailScreen({super.key, required this.patientId});
|
||
final String patientId;
|
||
|
||
@override
|
||
ConsumerState<ClinicPatientDetailScreen> createState() =>
|
||
_ClinicPatientDetailScreenState();
|
||
}
|
||
|
||
class _ClinicPatientDetailScreenState
|
||
extends ConsumerState<ClinicPatientDetailScreen> {
|
||
late Future<Patient> _future;
|
||
late Future<List<Job>> _jobsFuture;
|
||
bool _editMode = false;
|
||
bool _isSaving = false;
|
||
|
||
final _patientCodeController = TextEditingController();
|
||
final _firstNameController = TextEditingController();
|
||
final _lastNameController = TextEditingController();
|
||
final _phoneController = TextEditingController();
|
||
final _notesController = TextEditingController();
|
||
String? _birthDate;
|
||
|
||
final _formKey = GlobalKey<FormState>();
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_load();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_patientCodeController.dispose();
|
||
_firstNameController.dispose();
|
||
_lastNameController.dispose();
|
||
_phoneController.dispose();
|
||
_notesController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _load() {
|
||
setState(() {
|
||
_future = ClinicPatientsRepository.instance
|
||
.getPatient(widget.patientId)
|
||
.then((p) {
|
||
_populateControllers(p);
|
||
return p;
|
||
});
|
||
_jobsFuture = ClinicJobsRepository.instance
|
||
.listJobsByPatient(widget.patientId);
|
||
});
|
||
}
|
||
|
||
void _populateControllers(Patient p) {
|
||
_patientCodeController.text = p.patientCode;
|
||
_firstNameController.text = p.firstName ?? '';
|
||
_lastNameController.text = p.lastName ?? '';
|
||
_phoneController.text = p.phone ?? '';
|
||
_notesController.text = p.notes ?? '';
|
||
_birthDate = p.birthDate;
|
||
}
|
||
|
||
Future<void> _pickBirthDate() async {
|
||
DateTime initial = DateTime(1990);
|
||
if (_birthDate != null) {
|
||
try {
|
||
initial = DateTime.parse(_birthDate!);
|
||
} catch (_) {}
|
||
}
|
||
final picked = await showDatePicker(
|
||
context: context,
|
||
initialDate: initial,
|
||
firstDate: DateTime(1900),
|
||
lastDate: DateTime.now(),
|
||
);
|
||
if (picked != null) {
|
||
setState(() {
|
||
_birthDate = picked.toIso8601String().split('T').first;
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _save() async {
|
||
if (!_formKey.currentState!.validate()) return;
|
||
setState(() => _isSaving = true);
|
||
try {
|
||
final patch = <String, dynamic>{
|
||
'patient_code': _patientCodeController.text.trim(),
|
||
'first_name': _firstNameController.text.trim().isNotEmpty
|
||
? _firstNameController.text.trim()
|
||
: null,
|
||
'last_name': _lastNameController.text.trim().isNotEmpty
|
||
? _lastNameController.text.trim()
|
||
: null,
|
||
'phone': _phoneController.text.trim().isNotEmpty
|
||
? _phoneController.text.trim()
|
||
: null,
|
||
'birth_date': _birthDate,
|
||
'notes': _notesController.text.trim().isNotEmpty
|
||
? _notesController.text.trim()
|
||
: null,
|
||
};
|
||
final updated = await ClinicPatientsRepository.instance
|
||
.updatePatient(widget.patientId, patch);
|
||
_populateControllers(updated);
|
||
setState(() {
|
||
_editMode = false;
|
||
_future = Future.value(updated);
|
||
});
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Hasta bilgileri güncellendi.')),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('Hata: $e')),
|
||
);
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _isSaving = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _delete() async {
|
||
// Check for existing jobs first
|
||
List<Job>? jobs;
|
||
try {
|
||
jobs = await ClinicJobsRepository.instance
|
||
.listJobsByPatient(widget.patientId, limit: 1);
|
||
} catch (_) {
|
||
jobs = null;
|
||
}
|
||
|
||
if (!mounted) return;
|
||
final hasJobs = (jobs?.isNotEmpty) ?? false;
|
||
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (_) => AlertDialog(
|
||
title: const Text('Hastayı Sil'),
|
||
content: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text('Bu hastayı silmek istediğinizden emin misiniz?'),
|
||
if (hasJobs) ...[
|
||
const SizedBox(height: 12),
|
||
Container(
|
||
padding: const EdgeInsets.all(10),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.cancelledBg,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: const Row(
|
||
children: [
|
||
Icon(Icons.warning_amber_rounded,
|
||
color: AppColors.cancelled, size: 18),
|
||
SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
'Bu hastaya ait işler bulunmaktadır. Hasta silinirse bu bağlantı kopar.',
|
||
style: TextStyle(
|
||
fontSize: 13, color: AppColors.cancelled),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context, false),
|
||
child: const Text('Vazgeç')),
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(context, true),
|
||
style:
|
||
FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
|
||
child: const Text('Sil'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
if (confirmed != true || !mounted) return;
|
||
|
||
try {
|
||
await ClinicPatientsRepository.instance.deletePatient(widget.patientId);
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Hasta silindi.')),
|
||
);
|
||
Navigator.of(context).pop(true); // signal that a delete happened
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('Silme hatası: $e')),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('Hasta Detayı'),
|
||
actions: [
|
||
if (!_editMode) ...[
|
||
IconButton(
|
||
icon: const Icon(Icons.edit_outlined),
|
||
tooltip: 'Düzenle',
|
||
onPressed: () => setState(() => _editMode = true),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.delete_outline, color: AppColors.cancelled),
|
||
tooltip: 'Sil',
|
||
onPressed: _delete,
|
||
),
|
||
] else ...[
|
||
if (_isSaving)
|
||
const Padding(
|
||
padding: EdgeInsets.all(12),
|
||
child: SizedBox(
|
||
width: 20,
|
||
height: 20,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
),
|
||
)
|
||
else ...[
|
||
TextButton(
|
||
onPressed: () {
|
||
setState(() => _editMode = false);
|
||
_future.then(_populateControllers);
|
||
},
|
||
child: const Text('İptal'),
|
||
),
|
||
FilledButton(
|
||
onPressed: _save,
|
||
child: const Text('Kaydet'),
|
||
),
|
||
const SizedBox(width: 8),
|
||
],
|
||
],
|
||
],
|
||
),
|
||
body: FutureBuilder<Patient>(
|
||
future: _future,
|
||
builder: (ctx, snap) {
|
||
if (snap.connectionState == ConnectionState.waiting) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
if (snap.hasError) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text('Hata: ${snap.error}'),
|
||
const SizedBox(height: 12),
|
||
FilledButton(
|
||
onPressed: _load,
|
||
child: const Text('Tekrar Dene'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
if (_editMode) {
|
||
return _EditForm(
|
||
formKey: _formKey,
|
||
patientCodeController: _patientCodeController,
|
||
firstNameController: _firstNameController,
|
||
lastNameController: _lastNameController,
|
||
phoneController: _phoneController,
|
||
notesController: _notesController,
|
||
birthDate: _birthDate,
|
||
onPickBirthDate: _pickBirthDate,
|
||
);
|
||
}
|
||
|
||
final patient = snap.data!;
|
||
return _PatientView(patient: patient, jobsFuture: _jobsFuture);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── View ───────────────────────────────────────────────────────────────────
|
||
|
||
class _PatientView extends StatelessWidget {
|
||
const _PatientView({required this.patient, required this.jobsFuture});
|
||
final Patient patient;
|
||
final Future<List<Job>> jobsFuture;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ListView(
|
||
padding: const EdgeInsets.all(16),
|
||
children: [
|
||
// Avatar + name header
|
||
Center(
|
||
child: Column(
|
||
children: [
|
||
Container(
|
||
width: 72,
|
||
height: 72,
|
||
decoration: BoxDecoration(
|
||
color: AppColors.inProgressBg,
|
||
borderRadius: BorderRadius.circular(20)),
|
||
child: Center(
|
||
child: Text(
|
||
patient.displayName.isNotEmpty
|
||
? patient.displayName[0].toUpperCase()
|
||
: '?',
|
||
style: const TextStyle(
|
||
fontSize: 28,
|
||
fontWeight: FontWeight.w800,
|
||
color: AppColors.inProgress),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
patient.displayName,
|
||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||
fontWeight: FontWeight.bold,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
Text(
|
||
patient.patientCode,
|
||
style: const TextStyle(
|
||
fontSize: 13, color: AppColors.textSecondary),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 24),
|
||
|
||
Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surface,
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(color: AppColors.border),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: 0.05),
|
||
blurRadius: 12,
|
||
offset: const Offset(0, 4))
|
||
],
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_DetailRow(label: 'Hasta Kodu', value: patient.patientCode),
|
||
if (patient.firstName != null)
|
||
_DetailRow(label: 'Ad', value: patient.firstName!),
|
||
if (patient.lastName != null)
|
||
_DetailRow(label: 'Soyad', value: patient.lastName!),
|
||
if (patient.phone != null && patient.phone!.isNotEmpty)
|
||
_DetailRow(label: 'Telefon', value: patient.phone!),
|
||
if (patient.birthDate != null && patient.birthDate!.isNotEmpty)
|
||
_DetailRow(
|
||
label: 'Doğum Tarihi', value: patient.birthDate!),
|
||
if (patient.notes != null && patient.notes!.isNotEmpty)
|
||
_DetailRow(label: 'Notlar', value: patient.notes!),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 24),
|
||
|
||
// Job history
|
||
_JobHistory(jobsFuture: jobsFuture),
|
||
const SizedBox(height: 24),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Job history ────────────────────────────────────────────────────────────
|
||
|
||
class _JobHistory extends StatelessWidget {
|
||
const _JobHistory({required this.jobsFuture});
|
||
final Future<List<Job>> jobsFuture;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text(
|
||
'İŞ GEÇMİŞİ',
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.textSecondary,
|
||
letterSpacing: 0.5,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
FutureBuilder<List<Job>>(
|
||
future: jobsFuture,
|
||
builder: (ctx, snap) {
|
||
if (snap.connectionState == ConnectionState.waiting) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
if (snap.hasError) {
|
||
return Text('Yüklenemedi: ${snap.error}',
|
||
style:
|
||
const TextStyle(color: AppColors.textSecondary));
|
||
}
|
||
final jobs = snap.data ?? [];
|
||
if (jobs.isEmpty) {
|
||
return Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surface,
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(color: AppColors.border),
|
||
),
|
||
child: const Center(
|
||
child: Text(
|
||
'Henüz iş kaydı yok.',
|
||
style: TextStyle(color: AppColors.textSecondary),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surface,
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(color: AppColors.border),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: 0.05),
|
||
blurRadius: 12,
|
||
offset: const Offset(0, 4))
|
||
],
|
||
),
|
||
child: Column(
|
||
children: jobs.asMap().entries.map((entry) {
|
||
final i = entry.key;
|
||
final job = entry.value;
|
||
final isLast = i == jobs.length - 1;
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 16, vertical: 12),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(
|
||
color: _statusBg(job.status),
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: Icon(
|
||
_statusIcon(job.status),
|
||
color: _statusColor(job.status),
|
||
size: 20,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment:
|
||
CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
job.patientCode,
|
||
style: const TextStyle(
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.textPrimary,
|
||
fontSize: 14,
|
||
),
|
||
),
|
||
Text(
|
||
'${job.prostheticType.label} · ${_statusLabel(job.status)}',
|
||
style: const TextStyle(
|
||
fontSize: 12,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Text(
|
||
_formatDate(job.dateCreated),
|
||
style: const TextStyle(
|
||
fontSize: 12,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (!isLast)
|
||
const Divider(
|
||
height: 1,
|
||
indent: 68,
|
||
color: AppColors.border),
|
||
],
|
||
);
|
||
}).toList(),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
static Color _statusBg(JobStatus s) => switch (s) {
|
||
JobStatus.delivered => AppColors.successBg,
|
||
JobStatus.cancelled => AppColors.cancelledBg,
|
||
JobStatus.inProgress => AppColors.inProgressBg,
|
||
_ => AppColors.pendingBg,
|
||
};
|
||
|
||
static Color _statusColor(JobStatus s) => switch (s) {
|
||
JobStatus.delivered => AppColors.success,
|
||
JobStatus.cancelled => AppColors.cancelled,
|
||
JobStatus.inProgress => AppColors.inProgress,
|
||
_ => AppColors.pending,
|
||
};
|
||
|
||
static IconData _statusIcon(JobStatus s) => switch (s) {
|
||
JobStatus.delivered => Icons.check_circle_outline,
|
||
JobStatus.cancelled => Icons.cancel_outlined,
|
||
JobStatus.inProgress => Icons.autorenew,
|
||
_ => Icons.hourglass_empty_outlined,
|
||
};
|
||
|
||
static String _statusLabel(JobStatus s) => switch (s) {
|
||
JobStatus.pending => 'Bekliyor',
|
||
JobStatus.inProgress => 'Üretimde',
|
||
JobStatus.sent => 'Gönderildi',
|
||
JobStatus.delivered => 'Teslim Edildi',
|
||
JobStatus.cancelled => 'İptal',
|
||
};
|
||
|
||
static String _formatDate(DateTime d) {
|
||
return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
|
||
}
|
||
}
|
||
|
||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||
|
||
class _DetailRow extends StatelessWidget {
|
||
const _DetailRow({required this.label, required this.value});
|
||
final String label;
|
||
final String value;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SizedBox(
|
||
width: 120,
|
||
child: Text(
|
||
label,
|
||
style: const TextStyle(
|
||
fontSize: 13, color: AppColors.textSecondary),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Text(
|
||
value,
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w500,
|
||
color: AppColors.textPrimary),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Edit form ──────────────────────────────────────────────────────────────
|
||
|
||
class _EditForm extends StatelessWidget {
|
||
const _EditForm({
|
||
required this.formKey,
|
||
required this.patientCodeController,
|
||
required this.firstNameController,
|
||
required this.lastNameController,
|
||
required this.phoneController,
|
||
required this.notesController,
|
||
required this.birthDate,
|
||
required this.onPickBirthDate,
|
||
});
|
||
|
||
final GlobalKey<FormState> formKey;
|
||
final TextEditingController patientCodeController;
|
||
final TextEditingController firstNameController;
|
||
final TextEditingController lastNameController;
|
||
final TextEditingController phoneController;
|
||
final TextEditingController notesController;
|
||
final String? birthDate;
|
||
final VoidCallback onPickBirthDate;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SingleChildScrollView(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Form(
|
||
key: formKey,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
TextFormField(
|
||
controller: patientCodeController,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Hasta Kodu *',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
validator: (val) =>
|
||
(val == null || val.trim().isEmpty)
|
||
? 'Hasta kodu zorunludur'
|
||
: null,
|
||
),
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: TextFormField(
|
||
controller: firstNameController,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Ad',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: TextFormField(
|
||
controller: lastNameController,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Soyad',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextFormField(
|
||
controller: phoneController,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Telefon',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
keyboardType: TextInputType.phone,
|
||
),
|
||
const SizedBox(height: 12),
|
||
InkWell(
|
||
onTap: onPickBirthDate,
|
||
child: InputDecorator(
|
||
decoration: const InputDecoration(
|
||
labelText: 'Doğum Tarihi',
|
||
border: OutlineInputBorder(),
|
||
suffixIcon: Icon(Icons.calendar_today),
|
||
),
|
||
child: Text(
|
||
birthDate ?? 'Tarih seçin',
|
||
style: birthDate != null
|
||
? null
|
||
: TextStyle(
|
||
color: Theme.of(context)
|
||
.colorScheme
|
||
.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextFormField(
|
||
controller: notesController,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Notlar',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
minLines: 3,
|
||
maxLines: 5,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|