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
This commit is contained in:
Emre Emir
2026-06-11 15:57:31 +03:00
commit 8bbc9dbff2
226 changed files with 31308 additions and 0 deletions
@@ -0,0 +1,576 @@
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),
],
),
),
),
);
}
}