Files
lab-app/lib/features/clinic/patients/clinic_patients_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

577 lines
19 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 '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: () {
_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();
// Pop using form's own context (inside dialog) to ensure correct Navigator
if (mounted) Navigator.of(context).pop();
} 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),
],
),
),
),
);
}
}