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,92 @@
import 'package:pocketbase/pocketbase.dart';
import '../../../core/api/pocketbase_client.dart';
import '../../../models/clinic_discount.dart';
class DiscountRepository {
DiscountRepository._();
static final instance = DiscountRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<List<ClinicDiscount>> listDiscounts(String labTenantId) async {
final result = await _pb.collection('clinic_discounts').getList(
filter: 'lab_tenant_id = "$labTenantId"',
expand: 'clinic_tenant_id',
perPage: 200,
);
final list = result.items
.map((r) => ClinicDiscount.fromJson(r.toJson()))
.toList();
list.sort((a, b) {
// Active first, then by clinic name
if (a.isActive != b.isActive) return a.isActive ? -1 : 1;
final ca = a.clinicName ?? '';
final cb = b.clinicName ?? '';
return ca.compareTo(cb);
});
return list;
}
Future<ClinicDiscount> createDiscount({
required String labTenantId,
String? clinicTenantId,
String? prostheticType,
required DiscountType discountType,
required double discountValue,
int minQuantity = 0,
bool isActive = true,
String? notes,
}) async {
final body = <String, dynamic>{
'lab_tenant_id': labTenantId,
'discount_type': discountType.value,
'discount_value': discountValue,
'is_active': isActive,
};
if (clinicTenantId != null && clinicTenantId.isNotEmpty) {
body['clinic_tenant_id'] = clinicTenantId;
}
if (prostheticType != null && prostheticType.isNotEmpty) {
body['prosthetic_type'] = prostheticType;
}
if (minQuantity > 0) body['min_quantity'] = minQuantity;
if (notes != null && notes.isNotEmpty) body['notes'] = notes;
final record = await _pb.collection('clinic_discounts').create(
body: body,
expand: 'clinic_tenant_id',
);
return ClinicDiscount.fromJson(record.toJson());
}
Future<ClinicDiscount> updateDiscount(
String id, {
String? clinicTenantId,
String? prostheticType,
DiscountType? discountType,
double? discountValue,
int? minQuantity,
bool? isActive,
String? notes,
}) async {
final body = <String, dynamic>{};
if (clinicTenantId != null) body['clinic_tenant_id'] = clinicTenantId.isEmpty ? null : clinicTenantId;
if (prostheticType != null) body['prosthetic_type'] = prostheticType.isEmpty ? '' : prostheticType;
if (discountType != null) body['discount_type'] = discountType.value;
if (discountValue != null) body['discount_value'] = discountValue;
if (minQuantity != null) body['min_quantity'] = minQuantity;
if (isActive != null) body['is_active'] = isActive;
if (notes != null) body['notes'] = notes;
final record = await _pb.collection('clinic_discounts').update(
id,
body: body,
expand: 'clinic_tenant_id',
);
return ClinicDiscount.fromJson(record.toJson());
}
Future<void> deleteDiscount(String id) async {
await _pb.collection('clinic_discounts').delete(id);
}
}
@@ -0,0 +1,940 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/pocketbase_client.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/gradient_app_bar.dart';
import '../../../models/clinic_discount.dart';
import 'discount_repository.dart';
// Simple local record for clinic picker
class _ClinicOption {
const _ClinicOption({required this.id, required this.name});
final String id;
final String name;
}
class DiscountsScreen extends ConsumerStatefulWidget {
const DiscountsScreen({super.key});
@override
ConsumerState<DiscountsScreen> createState() => _DiscountsScreenState();
}
class _DiscountsScreenState extends ConsumerState<DiscountsScreen> {
late Future<List<ClinicDiscount>> _future;
String _searchQuery = '';
final _searchController = TextEditingController();
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _load() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() {
_future = DiscountRepository.instance.listDiscounts(tenantId);
});
}
Future<void> _showSheet({ClinicDiscount? existing}) async {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
final result = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _DiscountSheet(
labTenantId: tenantId,
existing: existing,
),
);
if (result == true) _load();
}
Future<void> _delete(ClinicDiscount discount) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('İndirimi Sil'),
content: Text(
'${discount.clinicName ?? 'Tüm Klinikler'}${discount.displayValue} indirimi silinsin mi?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('İptal')),
FilledButton(
style:
FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Sil'),
),
],
),
);
if (confirmed != true) return;
try {
await DiscountRepository.instance.deleteDiscount(discount.id);
_load();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Hata: $e'),
backgroundColor: AppColors.cancelled),
);
}
}
}
Future<void> _toggleActive(ClinicDiscount discount) async {
try {
await DiscountRepository.instance
.updateDiscount(discount.id, isActive: !discount.isActive);
_load();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Hata: $e'),
backgroundColor: AppColors.cancelled),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: GradientAppBar(
title: 'İndirimler',
category: 'LABORATUVAR',
searchController: _searchController,
onSearchChanged: (v) => setState(() => _searchQuery = v),
searchHint: 'Klinik veya ürün tipi ara...',
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showSheet(),
backgroundColor: AppColors.accent,
foregroundColor: Colors.white,
icon: const Icon(Icons.add),
label: const Text('Yeni İndirim'),
),
body: FutureBuilder<List<ClinicDiscount>>(
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: [
const Icon(Icons.wifi_off_rounded,
color: AppColors.cancelled, size: 40),
const SizedBox(height: 12),
Text('Hata: ${snap.error}',
style:
const TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh_rounded, size: 16),
label: const Text('Tekrar Dene')),
],
),
);
}
final allDiscounts = snap.data!;
final q = _searchQuery.toLowerCase().trim();
final discounts = q.isEmpty
? allDiscounts
: allDiscounts
.where((d) =>
(d.clinicName ?? 'tüm klinikler')
.toLowerCase()
.contains(q) ||
d.prostheticLabel.toLowerCase().contains(q))
.toList();
if (allDiscounts.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.discount_outlined,
size: 32, color: AppColors.inProgress),
),
const SizedBox(height: 16),
const Text('Henüz indirim tanımlanmadı',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary)),
const SizedBox(height: 8),
const Text(
'Klinik ve ürün bazlı özel indirimler ekleyin.',
style: TextStyle(
color: AppColors.textSecondary, fontSize: 13),
textAlign: TextAlign.center),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: () => _showSheet(),
icon: const Icon(Icons.add),
label: const Text('İlk İndirimi Ekle'),
),
],
),
);
}
if (discounts.isEmpty) {
return const Center(
child: Text('Sonuç bulunamadı',
style: TextStyle(color: AppColors.textSecondary)),
);
}
final active = discounts.where((d) => d.isActive).toList();
final inactive = discounts.where((d) => !d.isActive).toList();
return RefreshIndicator(
color: AppColors.accent,
onRefresh: () async => _load(),
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
children: [
if (active.isNotEmpty) ...[
_GroupHeader('Aktif (${active.length})'),
for (final d in active)
_DiscountCard(
discount: d,
onEdit: () => _showSheet(existing: d),
onDelete: () => _delete(d),
onToggle: () => _toggleActive(d),
),
],
if (inactive.isNotEmpty) ...[
const SizedBox(height: 8),
_GroupHeader('Pasif (${inactive.length})'),
for (final d in inactive)
_DiscountCard(
discount: d,
onEdit: () => _showSheet(existing: d),
onDelete: () => _delete(d),
onToggle: () => _toggleActive(d),
),
],
],
),
);
},
),
);
}
}
// ── Group header ──────────────────────────────────────────────────────────────
class _GroupHeader extends StatelessWidget {
const _GroupHeader(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8, top: 4),
child: Text(text,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppColors.textMuted,
letterSpacing: 0.5)),
);
}
}
// ── Discount card ─────────────────────────────────────────────────────────────
class _DiscountCard extends StatelessWidget {
const _DiscountCard({
required this.discount,
required this.onEdit,
required this.onDelete,
required this.onToggle,
});
final ClinicDiscount discount;
final VoidCallback onEdit;
final VoidCallback onDelete;
final VoidCallback onToggle;
@override
Widget build(BuildContext context) {
final d = discount;
final isActive = d.isActive;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Material(
color: isActive ? AppColors.surface : AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onEdit,
borderRadius: BorderRadius.circular(14),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isActive ? AppColors.border : AppColors.muted),
boxShadow: isActive
? [
BoxShadow(
color: Colors.black.withValues(alpha: 0.03),
blurRadius: 6,
offset: const Offset(0, 2))
]
: [],
),
child: IntrinsicHeight(
child: Row(
children: [
Container(
width: 4,
decoration: BoxDecoration(
color:
isActive ? AppColors.success : AppColors.border,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(14),
bottomLeft: Radius.circular(14),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 4, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: isActive
? AppColors.successBg
: AppColors.background,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${d.displayValue} İndirim',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: isActive
? AppColors.success
: AppColors.textMuted,
),
),
),
if (d.minQuantity > 0) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7, vertical: 4),
decoration: BoxDecoration(
color: AppColors.pendingBg,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${d.minQuantity} adet',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.pending),
),
),
],
const Spacer(),
Transform.scale(
scale: 0.8,
child: Switch(
value: isActive,
onChanged: (_) => onToggle(),
activeColor: AppColors.success,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
_Tag(
icon: Icons.local_hospital_outlined,
label: d.appliesToAll
? 'Tüm Klinikler'
: (d.clinicName ?? 'Klinik'),
color: AppColors.inProgress,
),
const SizedBox(width: 6),
_Tag(
icon: Icons.science_outlined,
label: d.prostheticLabel,
color: AppColors.accent,
),
],
),
if (d.notes != null && d.notes!.isNotEmpty) ...[
const SizedBox(height: 6),
Text(d.notes!,
style: const TextStyle(
fontSize: 12,
color: AppColors.textMuted),
maxLines: 1,
overflow: TextOverflow.ellipsis),
],
],
),
),
),
IconButton(
onPressed: onDelete,
icon: const Icon(Icons.delete_outline_rounded,
size: 18, color: AppColors.cancelled),
tooltip: 'Sil',
),
],
),
),
),
),
),
);
}
}
class _Tag extends StatelessWidget {
const _Tag(
{required this.icon, required this.label, required this.color});
final IconData icon;
final String label;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 11, color: color),
const SizedBox(width: 4),
Text(label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: color)),
],
),
);
}
}
// ── Discount sheet ────────────────────────────────────────────────────────────
class _DiscountSheet extends StatefulWidget {
const _DiscountSheet({required this.labTenantId, this.existing});
final String labTenantId;
final ClinicDiscount? existing;
@override
State<_DiscountSheet> createState() => _DiscountSheetState();
}
class _DiscountSheetState extends State<_DiscountSheet> {
final _valueCtrl = TextEditingController();
final _minQtyCtrl = TextEditingController();
final _notesCtrl = TextEditingController();
DiscountType _discountType = DiscountType.percentage;
String? _selectedClinicId;
String? _selectedType;
bool _isActive = true;
bool _saving = false;
List<_ClinicOption>? _clinics;
@override
void initState() {
super.initState();
final e = widget.existing;
if (e != null) {
_valueCtrl.text = e.discountValue.toStringAsFixed(
e.discountValue % 1 == 0 ? 0 : 2);
_minQtyCtrl.text =
e.minQuantity > 0 ? e.minQuantity.toString() : '';
_notesCtrl.text = e.notes ?? '';
_discountType = e.discountType;
_selectedClinicId = e.clinicTenantId;
_selectedType = e.prostheticType;
_isActive = e.isActive;
}
_loadClinics();
}
Future<void> _loadClinics() async {
try {
final pb = PocketBaseClient.instance.pb;
final result = await pb.collection('tenant_connections').getList(
filter:
'lab_tenant_id = "${widget.labTenantId}" && status = "approved"',
expand: 'clinic_tenant_id',
perPage: 100,
);
final clinics = result.items.map((r) {
final j = r.toJson();
final expand = j['expand'] as Map<String, dynamic>?;
final clinic =
expand?['clinic_tenant_id'] as Map<String, dynamic>?;
return _ClinicOption(
id: j['clinic_tenant_id'] as String? ?? '',
name: clinic?['company_name'] as String? ?? 'Klinik',
);
}).where((c) => c.id.isNotEmpty).toList();
if (mounted) setState(() => _clinics = clinics);
} catch (_) {
if (mounted) setState(() => _clinics = []);
}
}
@override
void dispose() {
_valueCtrl.dispose();
_minQtyCtrl.dispose();
_notesCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
final valueStr = _valueCtrl.text.trim().replaceAll(',', '.');
final value = double.tryParse(valueStr);
if (value == null || value <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Geçerli bir indirim değeri girin.'),
backgroundColor: AppColors.cancelled),
);
return;
}
if (_discountType == DiscountType.percentage && value > 100) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Yüzde indirim 100'ü geçemez."),
backgroundColor: AppColors.cancelled),
);
return;
}
final minQty = int.tryParse(_minQtyCtrl.text.trim()) ?? 0;
setState(() => _saving = true);
final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
try {
if (widget.existing != null) {
await DiscountRepository.instance.updateDiscount(
widget.existing!.id,
clinicTenantId: _selectedClinicId ?? '',
prostheticType: _selectedType ?? '',
discountType: _discountType,
discountValue: value,
minQuantity: minQty,
isActive: _isActive,
notes: _notesCtrl.text.trim(),
);
} else {
await DiscountRepository.instance.createDiscount(
labTenantId: widget.labTenantId,
clinicTenantId: _selectedClinicId,
prostheticType: _selectedType,
discountType: _discountType,
discountValue: value,
minQuantity: minQty,
isActive: _isActive,
notes: _notesCtrl.text.trim(),
);
}
navigator.pop(true);
} catch (e) {
if (mounted) setState(() => _saving = false);
messenger.showSnackBar(
SnackBar(
content: Text('Hata: $e'),
backgroundColor: AppColors.cancelled),
);
}
}
static const _prostheticTypes = [
('', 'Tüm Türler'),
('metal_porselen', 'Metal Porselen'),
('zirkonyum', 'Zirkonyum'),
('implant_ustu_zirkonyum', 'İmplant Üstü Zirkonyum'),
('gecici', 'Geçici'),
('e_max', 'E-Max'),
('tam_protez', 'Tam Protez'),
('parsiyel', 'Parsiyel Protez'),
('diger', 'Diğer'),
];
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.paddingOf(context).bottom;
return Container(
decoration: const BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
padding: EdgeInsets.only(bottom: bottom),
child: SingleChildScrollView(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.viewInsetsOf(context).bottom + 20,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppColors.border,
borderRadius: BorderRadius.circular(2)),
),
),
const SizedBox(height: 16),
Text(
widget.existing != null
? 'İndirimi Düzenle'
: 'Yeni İndirim',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary),
),
const SizedBox(height: 20),
const Text('İndirim Türü',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _TypeButton(
label: 'Yüzde (%)',
icon: Icons.percent_rounded,
selected: _discountType == DiscountType.percentage,
onTap: () => setState(
() => _discountType = DiscountType.percentage),
),
),
const SizedBox(width: 10),
Expanded(
child: _TypeButton(
label: 'Sabit Tutar',
icon: Icons.currency_lira_rounded,
selected: _discountType == DiscountType.fixed,
onTap: () =>
setState(() => _discountType = DiscountType.fixed),
),
),
],
),
const SizedBox(height: 16),
const Text('İndirim Değeri',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary)),
const SizedBox(height: 8),
TextField(
controller: _valueCtrl,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
hintText: _discountType == DiscountType.percentage
? 'Örn: 10'
: 'Örn: 150',
suffixText:
_discountType == DiscountType.percentage ? '%' : 'TL',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(height: 16),
const Text('Klinik',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary)),
const SizedBox(height: 8),
_ClinicDropdown(
selectedId: _selectedClinicId,
clinics: _clinics,
onChanged: (id, _) => setState(() {
_selectedClinicId = id;
}),
),
const SizedBox(height: 16),
const Text('Ürün Tipi',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary)),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: _selectedType ?? '',
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 12),
),
items: _prostheticTypes
.map((t) =>
DropdownMenuItem(value: t.$1, child: Text(t.$2)))
.toList(),
onChanged: (v) =>
setState(() => _selectedType = v == '' ? null : v),
),
const SizedBox(height: 16),
const Text('Minimum Sipariş Adedi (İsteğe Bağlı)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary)),
const SizedBox(height: 4),
const Text(
'Aylık bu adede ulaşılınca indirim devreye girer. 0 = koşulsuz.',
style:
TextStyle(fontSize: 11, color: AppColors.textMuted)),
const SizedBox(height: 8),
TextField(
controller: _minQtyCtrl,
keyboardType: TextInputType.number,
decoration: InputDecoration(
hintText: '0',
suffixText: 'adet',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(height: 16),
const Text('Not (İsteğe Bağlı)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary)),
const SizedBox(height: 8),
TextField(
controller: _notesCtrl,
maxLines: 2,
decoration: InputDecoration(
hintText: 'Açıklama...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(height: 16),
Row(
children: [
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Aktif',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary)),
Text('Pasif indirimler uygulanmaz.',
style: TextStyle(
fontSize: 12, color: AppColors.textMuted)),
],
),
),
Switch(
value: _isActive,
onChanged: (v) => setState(() => _isActive = v),
activeColor: AppColors.success,
),
],
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 50,
child: FilledButton(
onPressed: _saving ? null : _save,
style: FilledButton.styleFrom(
backgroundColor: AppColors.accent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14)),
),
child: _saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: Text(
widget.existing != null ? 'Güncelle' : 'Kaydet',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600)),
),
),
],
),
),
);
}
}
class _TypeButton extends StatelessWidget {
const _TypeButton({
required this.label,
required this.icon,
required this.selected,
required this.onTap,
});
final String label;
final IconData icon;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: selected ? AppColors.accent : AppColors.background,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: selected ? AppColors.accent : AppColors.border),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon,
size: 16,
color: selected ? Colors.white : AppColors.textSecondary),
const SizedBox(width: 6),
Text(label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: selected
? Colors.white
: AppColors.textSecondary)),
],
),
),
);
}
}
class _ClinicDropdown extends StatelessWidget {
const _ClinicDropdown({
required this.selectedId,
required this.clinics,
required this.onChanged,
});
final String? selectedId;
final List<_ClinicOption>? clinics;
final void Function(String? id, String? name) onChanged;
@override
Widget build(BuildContext context) {
if (clinics == null) {
return Container(
height: 48,
decoration: BoxDecoration(
border: Border.all(color: AppColors.border),
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2))),
);
}
final items = <DropdownMenuItem<String>>[
const DropdownMenuItem(value: '', child: Text('Tüm Klinikler')),
for (final c in clinics!)
DropdownMenuItem(value: c.id, child: Text(c.name)),
];
return DropdownButtonFormField<String>(
value: selectedId ?? '',
decoration: InputDecoration(
border:
OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
),
items: items,
onChanged: (v) {
if (v == null || v.isEmpty) {
onChanged(null, null);
} else {
final clinic = clinics!.firstWhere((c) => c.id == v);
onChanged(v, clinic.name);
}
},
);
}
}