576 lines
19 KiB
Dart
576 lines
19 KiB
Dart
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),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|