Files
lab-app/lib/features/clinic/patients/clinic_patient_detail_screen.dart
T
Emre Emir 8bbc9dbff2 Initial commit: DLS - Dental Lab System
- Flutter + PocketBase dental lab management system
- Clinic & lab dashboards, job tracking, patient management
- Product catalog, finance tracking, multi-language support
- AI assistant integration, realtime notifications
- Windows installer (Inno Setup) included
- Developed by kovakyazilim.com
2026-06-11 15:57:31 +03:00

718 lines
24 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
),
],
),
),
);
}
}