Initial commit — DLS lab-app Flutter project

This commit is contained in:
egecankomur
2026-06-10 23:22:15 +03:00
commit d1acc1d367
225 changed files with 31294 additions and 0 deletions
@@ -0,0 +1,717 @@
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,
),
],
),
),
);
}
}