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,
),
],
),
),
);
}
}
@@ -0,0 +1,67 @@
import 'package:pocketbase/pocketbase.dart';
import '../../../core/api/pocketbase_client.dart';
import '../../../models/patient.dart';
class ClinicPatientsRepository {
ClinicPatientsRepository._();
static final instance = ClinicPatientsRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<List<Patient>> listPatients(
String tenantId, {
String? search,
int page = 1,
int limit = 30,
}) async {
final filterParts = ['tenant_id = "$tenantId"'];
if (search != null && search.isNotEmpty) {
filterParts.add(
'(patient_code ~ "$search" || first_name ~ "$search" || last_name ~ "$search")',
);
}
final result = await _pb.collection('patients').getList(
page: page,
perPage: limit,
filter: filterParts.join(' && '),
);
return (result.items.map((r) => Patient.fromJson(r.toJson())).toList()
..sort((a, b) => a.patientCode.compareTo(b.patientCode)));
}
Future<Patient> getPatient(String patientId) async {
final record = await _pb.collection('patients').getOne(patientId);
return Patient.fromJson(record.toJson());
}
Future<Patient> createPatient({
required String tenantId,
required String patientCode,
String? firstName,
String? lastName,
String? birthDate,
String? phone,
String? notes,
}) async {
final record = await _pb.collection('patients').create(body: {
'tenant_id': tenantId,
'patient_code': patientCode,
if (firstName != null) 'first_name': firstName,
if (lastName != null) 'last_name': lastName,
if (birthDate != null) 'birth_date': birthDate,
if (phone != null) 'phone': phone,
if (notes != null) 'notes': notes,
});
return Patient.fromJson(record.toJson());
}
Future<Patient> updatePatient(String patientId, Map<String, dynamic> patch) async {
final record = await _pb.collection('patients').update(patientId, body: patch);
return Patient.fromJson(record.toJson());
}
Future<void> deletePatient(String patientId) async {
await _pb.collection('patients').delete(patientId);
}
}
@@ -0,0 +1,575 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/gradient_app_bar.dart';
import '../../../models/patient.dart';
import 'clinic_patients_repository.dart';
enum _PatientSort { nameAZ, nameZA, byCode }
const _kSortLabels = [
'Ada Göre (A → Z)',
'Ada Göre (Z → A)',
'Hasta Koduna Göre',
];
void _showAdaptive(BuildContext context, Widget content) {
final isDesktop =
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
if (isDesktop) {
showDialog(
context: context,
builder: (_) => Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: content,
),
),
);
} else {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => content,
);
}
}
class ClinicPatientsScreen extends ConsumerStatefulWidget {
const ClinicPatientsScreen({super.key});
@override
ConsumerState<ClinicPatientsScreen> createState() =>
_ClinicPatientsScreenState();
}
class _ClinicPatientsScreenState extends ConsumerState<ClinicPatientsScreen> {
late Future<List<Patient>> _future;
final _searchController = TextEditingController();
String _searchQuery = '';
_PatientSort _sort = _PatientSort.nameAZ;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _load([String? search]) {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() {
_future = ClinicPatientsRepository.instance.listPatients(
tenantId,
search: search?.trim().isNotEmpty == true ? search : null,
limit: 100,
);
});
}
void _onSearchChanged(String value) {
setState(() => _searchQuery = value);
_load(value);
}
Future<void> _showSortOptions() async {
final result = await showSortSheet(
context,
title: 'Sıralama',
options: _kSortLabels,
current: _sort.index,
);
if (result != null) {
setState(() => _sort = _PatientSort.values[result]);
}
}
List<Patient> _sorted(List<Patient> patients) {
final list = List<Patient>.from(patients);
switch (_sort) {
case _PatientSort.nameAZ:
list.sort((a, b) => a.displayName.compareTo(b.displayName));
case _PatientSort.nameZA:
list.sort((a, b) => b.displayName.compareTo(a.displayName));
case _PatientSort.byCode:
list.sort((a, b) => a.patientCode.compareTo(b.patientCode));
}
return list;
}
void _showNewPatientSheet() {
_showAdaptive(
context,
_NewPatientSheet(
onCreated: () {
Navigator.of(context).pop();
_load(_searchQuery);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Hasta oluşturuldu.')),
);
},
),
);
}
@override
Widget build(BuildContext context) {
final isSortActive = _sort != _PatientSort.nameAZ;
return Scaffold(
backgroundColor: AppColors.background,
appBar: GradientAppBar(
title: 'Hastalar',
category: 'KLİNİK',
searchController: _searchController,
onSearchChanged: _onSearchChanged,
searchHint: 'Ad, soyad veya kod ile arayın...',
actions: [
IconButton(
onPressed: _showSortOptions,
tooltip: 'Sırala',
icon: Badge(
isLabelVisible: isSortActive,
smallSize: 8,
backgroundColor: AppColors.accent,
child: const Icon(Icons.sort_rounded),
),
),
IconButton(
onPressed: _showNewPatientSheet,
tooltip: 'Yeni Hasta',
icon: const Icon(Icons.person_add_outlined),
),
],
),
body: Column(
children: [
Expanded(
child: RefreshIndicator(
color: AppColors.accent,
onRefresh: () async => _load(_searchQuery),
child: FutureBuilder<List<Patient>>(
future: _future,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(
color: AppColors.accent));
}
if (snap.hasError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.cancelledBg,
borderRadius: BorderRadius.circular(16)),
child: const Icon(Icons.wifi_off_rounded,
color: AppColors.cancelled, size: 30),
),
const SizedBox(height: 16),
Text('Hata: ${snap.error}',
style: const TextStyle(
color: AppColors.textSecondary)),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () => _load(_searchQuery),
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Tekrar Dene'),
),
],
),
);
}
final patients = _sorted(snap.data!);
if (patients.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: AppColors.inProgressBg,
borderRadius: BorderRadius.circular(20)),
child: const Icon(Icons.people_outline,
color: AppColors.inProgress, size: 32),
),
const SizedBox(height: 16),
Text(
_searchQuery.isNotEmpty
? 'Sonuç bulunamadı'
: 'Henüz hasta yok',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
if (_searchQuery.isEmpty) ...[
const SizedBox(height: 8),
const Text(
'Yeni hasta eklemek için + düğmesine dokunun',
style: TextStyle(
fontSize: 13,
color: AppColors.textSecondary),
),
],
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
itemCount: patients.length,
itemBuilder: (context, index) {
final patient = patients[index];
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _PatientCard(
patient: patient,
onTap: () => context
.push('/clinic/patients/${patient.id}'),
),
);
},
);
},
),
),
),
],
),
);
}
}
class _PatientCard extends StatelessWidget {
const _PatientCard({required this.patient, required this.onTap});
final Patient patient;
final VoidCallback onTap;
String get _initials {
final name = patient.displayName;
if (name.isEmpty) return '?';
final parts = name.trim().split(' ');
if (parts.length >= 2) {
return '${parts.first[0]}${parts.last[0]}'.toUpperCase();
}
return name[0].toUpperCase();
}
@override
Widget build(BuildContext context) {
return Material(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2))
]),
child: Row(
children: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF1E3A5F), Color(0xFF0369A1)],
),
borderRadius: BorderRadius.circular(13),
),
child: Center(
child: Text(
_initials,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
patient.displayName.isNotEmpty
? patient.displayName
: patient.patientCode,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
const SizedBox(height: 2),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(6),
),
child: Text(
patient.patientCode,
style: const TextStyle(
fontSize: 11,
color: AppColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
),
if (patient.phone != null &&
patient.phone!.isNotEmpty) ...[
const SizedBox(width: 8),
const Icon(Icons.phone_outlined,
size: 11, color: AppColors.textMuted),
const SizedBox(width: 3),
Text(
patient.phone!,
style: const TextStyle(
fontSize: 11, color: AppColors.textMuted),
),
],
],
),
],
),
),
const Icon(Icons.chevron_right,
color: AppColors.textMuted, size: 20),
],
),
),
),
);
}
}
// ── New Patient Sheet ─────────────────────────────────────────────────────────
class _NewPatientSheet extends ConsumerStatefulWidget {
const _NewPatientSheet({required this.onCreated});
final VoidCallback onCreated;
@override
ConsumerState<_NewPatientSheet> createState() => _NewPatientSheetState();
}
class _NewPatientSheetState extends ConsumerState<_NewPatientSheet> {
final _formKey = GlobalKey<FormState>();
final _patientCodeController = TextEditingController();
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _phoneController = TextEditingController();
final _notesController = TextEditingController();
String? _birthDate;
bool _isSubmitting = false;
@override
void dispose() {
_patientCodeController.dispose();
_firstNameController.dispose();
_lastNameController.dispose();
_phoneController.dispose();
_notesController.dispose();
super.dispose();
}
Future<void> _pickBirthDate() async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime(1990),
firstDate: DateTime(1900),
lastDate: DateTime.now(),
);
if (picked != null) {
setState(() {
_birthDate = picked.toIso8601String().split('T').first;
});
}
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isSubmitting = true);
try {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
await ClinicPatientsRepository.instance.createPatient(
tenantId: tenantId,
patientCode: _patientCodeController.text.trim(),
firstName: _firstNameController.text.trim().isNotEmpty
? _firstNameController.text.trim()
: null,
lastName: _lastNameController.text.trim().isNotEmpty
? _lastNameController.text.trim()
: null,
phone: _phoneController.text.trim().isNotEmpty
? _phoneController.text.trim()
: null,
birthDate: _birthDate,
notes: _notesController.text.trim().isNotEmpty
? _notesController.text.trim()
: null,
);
widget.onCreated();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
final isDesktop =
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.vertical(
top: isDesktop ? Radius.zero : const Radius.circular(20),
),
),
padding: EdgeInsets.only(
bottom: isDesktop ? 0 : MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Text(
'Yeni Hasta',
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _patientCodeController,
decoration: const InputDecoration(
labelText: 'Hasta Kodu *',
hintText: 'Ör: P-001',
),
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'),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _lastNameController,
decoration: const InputDecoration(labelText: 'Soyad'),
),
),
],
),
const SizedBox(height: 12),
TextFormField(
controller: _phoneController,
decoration: const InputDecoration(labelText: 'Telefon'),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
InkWell(
onTap: _pickBirthDate,
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Doğum Tarihi',
suffixIcon: Icon(Icons.calendar_today),
),
child: Text(
_birthDate ?? 'Tarih seçin',
style: _birthDate != null
? null
: const TextStyle(color: AppColors.textMuted),
),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _notesController,
decoration: const InputDecoration(labelText: 'Notlar'),
minLines: 2,
maxLines: 3,
),
const SizedBox(height: 20),
if (_isSubmitting)
const Center(
child: CircularProgressIndicator(color: AppColors.accent))
else
FilledButton(
onPressed: _submit,
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
),
child: const Text('Hasta Oluştur'),
),
const SizedBox(height: 8),
],
),
),
),
);
}
}