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
+25
View File
@@ -0,0 +1,25 @@
import 'package:pocketbase/pocketbase.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _kAuthKey = 'pb_auth';
class PocketBaseClient {
PocketBaseClient._({required this.pb});
static PocketBaseClient? _instance;
static PocketBaseClient get instance => _instance!;
final PocketBase pb;
static Future<void> init() async {
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getString(_kAuthKey);
final store = AsyncAuthStore(
save: (String data) => prefs.setString(_kAuthKey, data),
initial: stored,
);
_instance = PocketBaseClient._(
pb: PocketBase('https://pocket.kovaksoft.com', authStore: store),
);
}
}
+100
View File
@@ -0,0 +1,100 @@
import 'package:pocketbase/pocketbase.dart';
import '../api/pocketbase_client.dart';
import '../../models/tenant.dart';
import '../../models/user_profile.dart';
class AuthRepository {
AuthRepository._();
static final instance = AuthRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<AuthResult> login(String email, String password) async {
await _pb.collection('users').authWithPassword(email, password);
return _buildAuthResult();
}
Future<void> logout() async {
_pb.authStore.clear();
}
Future<bool> isLoggedIn() async {
if (!_pb.authStore.isValid) return false;
try {
await _pb.collection('users').authRefresh();
return true;
} catch (_) {
_pb.authStore.clear();
return false;
}
}
Future<AuthResult> register({
required String email,
required String password,
String? firstName,
String? lastName,
}) async {
await _pb.collection('users').create(body: {
'email': email,
'password': password,
'passwordConfirm': password,
'emailVisibility': true,
if (firstName != null && firstName.isNotEmpty) 'first_name': firstName,
if (lastName != null && lastName.isNotEmpty) 'last_name': lastName,
});
return login(email, password);
}
Future<AuthResult> refreshSession() async {
try {
await _pb.collection('users').authRefresh();
} catch (_) {}
return _buildAuthResult();
}
Future<void> updateUserLanguage(String userId, String languageCode) async {
await _pb.collection('users').update(userId, body: {
'preferred_language': languageCode,
});
}
Future<void> updateTenant(
String id, {
String? companyName,
String? defaultCurrency,
}) async {
final body = <String, dynamic>{};
if (companyName != null) body['company_name'] = companyName;
if (defaultCurrency != null) body['default_currency'] = defaultCurrency;
if (body.isEmpty) return;
await _pb.collection('tenants').update(id, body: body);
}
Future<AuthResult> _buildAuthResult() async {
final record = _pb.authStore.record!;
final user = UserProfile.fromJson(record.toJson());
List<TenantMembership> tenants = [];
try {
tenants = await _fetchUserTenants(record.id);
} catch (_) {}
return AuthResult(user: user, tenants: tenants);
}
Future<List<TenantMembership>> _fetchUserTenants(String userId) async {
final result = await _pb.collection('tenant_members').getList(
filter: 'user_id = "$userId"',
expand: 'tenant_id',
perPage: 50,
);
return result.items
.map((r) => TenantMembership.fromJson(r.toJson()))
.toList();
}
}
class AuthResult {
const AuthResult({required this.user, required this.tenants});
final UserProfile user;
final List<TenantMembership> tenants;
}
+777
View File
@@ -0,0 +1,777 @@
// ignore_for_file: lines_longer_than_80_chars
class AppStrings {
const AppStrings({
required this.settings,
required this.userInfo,
required this.labInfo,
required this.clinicInfo,
required this.labName,
required this.clinicName,
required this.currency,
required this.status,
required this.active,
required this.role,
required this.connections,
required this.clinicConnections,
required this.clinicConnectionsSub,
required this.labConnections,
required this.labConnectionsSub,
required this.otherMemberships,
required this.management,
required this.team,
required this.teamSub,
required this.discounts,
required this.discountsSub,
required this.reports,
required this.reportsSub,
required this.aiAssistant,
required this.aiAssistantSub,
required this.signOut,
required this.signOutTitle,
required this.signOutConfirm,
required this.cancel,
required this.save,
required this.edit,
required this.editLabInfo,
required this.editClinicInfo,
required this.labNameHint,
required this.clinicNameHint,
required this.preferences,
required this.appLanguage,
required this.languageSelection,
required this.currencySelection,
required this.languageTurkish,
required this.languageEnglish,
required this.languageRussian,
required this.languageArabic,
required this.languageGerman,
required this.type,
required this.roleOwner,
required this.roleAdmin,
required this.roleTechnician,
required this.roleDelivery,
required this.roleFinance,
required this.roleDoctor,
required this.roleMember,
required this.tenantKindClinic,
required this.tenantKindLab,
required this.signInWelcome,
required this.signInSubtitle,
required this.emailAddress,
required this.password,
required this.emailRequired,
required this.passwordRequired,
required this.signIn,
required this.noAccount,
required this.signUp,
required this.signInHeadline,
required this.signInTagline,
required this.footerCopyright,
required this.signUpTitle,
required this.signUpSubtitle,
required this.firstName,
required this.lastName,
required this.firstNameHint,
required this.lastNameHint,
required this.emailHint,
required this.passwordHint,
required this.confirmPassword,
required this.confirmPasswordHint,
required this.passwordMismatch,
required this.alreadyHaveAccount,
required this.finance,
required this.pendingReceivable,
required this.collected,
required this.pending,
required this.sortNewest,
required this.sortAmountDesc,
required this.sortAmountAsc,
required this.noPendingEntries,
required this.noPaidEntries,
required this.sort,
required this.retry,
required this.errorPrefix,
required this.laboratoryCategory,
required this.clinicCategory,
required this.jobsTitle,
required this.dashboardTitle,
required this.productsTitle,
required this.patientsTitle,
required this.close,
required this.confirm,
required this.currencyTRY,
required this.currencyUSD,
required this.currencyEUR,
required this.currencyGBP,
required this.currencyAED,
});
// ── General ───────────────────────────────────────────────────────────────
final String cancel;
final String save;
final String edit;
final String preferences;
final String close;
final String confirm;
final String retry;
final String errorPrefix;
final String sort;
// ── Settings ──────────────────────────────────────────────────────────────
final String settings;
final String userInfo;
final String labInfo;
final String clinicInfo;
final String labName;
final String clinicName;
final String currency;
final String status;
final String active;
final String role;
final String connections;
final String clinicConnections;
final String clinicConnectionsSub;
final String labConnections;
final String labConnectionsSub;
final String otherMemberships;
final String management;
final String team;
final String teamSub;
final String discounts;
final String discountsSub;
final String reports;
final String reportsSub;
final String aiAssistant;
final String aiAssistantSub;
final String signOut;
final String signOutTitle;
final String signOutConfirm;
final String editLabInfo;
final String editClinicInfo;
final String labNameHint;
final String clinicNameHint;
final String appLanguage;
final String languageSelection;
final String currencySelection;
final String languageTurkish;
final String languageEnglish;
final String languageRussian;
final String languageArabic;
final String languageGerman;
final String type;
// ── Roles & tenant ────────────────────────────────────────────────────────
final String roleOwner;
final String roleAdmin;
final String roleTechnician;
final String roleDelivery;
final String roleFinance;
final String roleDoctor;
final String roleMember;
final String tenantKindClinic;
final String tenantKindLab;
// ── Auth ──────────────────────────────────────────────────────────────────
final String signInWelcome;
final String signInSubtitle;
final String emailAddress;
final String password;
final String emailRequired;
final String passwordRequired;
final String signIn;
final String noAccount;
final String signUp;
final String signInHeadline;
final String signInTagline;
final String footerCopyright;
final String signUpTitle;
final String signUpSubtitle;
final String firstName;
final String lastName;
final String firstNameHint;
final String lastNameHint;
final String emailHint;
final String passwordHint;
final String confirmPassword;
final String confirmPasswordHint;
final String passwordMismatch;
final String alreadyHaveAccount;
// ── Finance ───────────────────────────────────────────────────────────────
final String finance;
final String pendingReceivable;
final String collected;
final String pending;
final String sortNewest;
final String sortAmountDesc;
final String sortAmountAsc;
final String noPendingEntries;
final String noPaidEntries;
// ── Navigation / categories ───────────────────────────────────────────────
final String laboratoryCategory;
final String clinicCategory;
final String jobsTitle;
final String dashboardTitle;
final String productsTitle;
final String patientsTitle;
// ── Currencies ────────────────────────────────────────────────────────────
final String currencyTRY;
final String currencyUSD;
final String currencyEUR;
final String currencyGBP;
final String currencyAED;
// ── Helpers ───────────────────────────────────────────────────────────────
String tenantSelected(String name) {
if (this == ar) return '$name تم الاختيار.';
if (this == ru) return '$name выбрана.';
if (this == de) return '$name ausgewählt.';
if (this == en) return '$name selected.';
return '$name seçildi.';
}
static AppStrings of(String languageCode) => switch (languageCode) {
'en' => en,
'ru' => ru,
'ar' => ar,
'de' => de,
_ => tr,
};
// ── Turkish ───────────────────────────────────────────────────────────────
static const tr = AppStrings(
cancel: 'İptal',
save: 'Kaydet',
edit: 'Düzenle',
preferences: 'Tercihler',
close: 'Kapat',
confirm: 'Onayla',
retry: 'Tekrar Dene',
errorPrefix: 'Hata',
sort: 'Sıralama',
settings: 'Ayarlar',
userInfo: 'Kullanıcı Bilgileri',
labInfo: 'Laboratuvar Bilgileri',
clinicInfo: 'Klinik Bilgileri',
labName: 'Laboratuvar Adı',
clinicName: 'Klinik Adı',
currency: 'Para Birimi',
status: 'Durum',
active: 'Aktif',
role: 'Rol',
connections: 'Bağlantılar',
clinicConnections: 'Klinik Bağlantıları',
clinicConnectionsSub: 'Bağlı klinikler ve istekler',
labConnections: 'Laboratuvar Bağlantıları',
labConnectionsSub: 'Bağlı lablar ve talepler',
otherMemberships: 'Diğer Üyelikler',
management: 'Yönetim',
team: 'Ekip',
teamSub: 'Üyeler ve davetler',
discounts: 'İndirimler',
discountsSub: 'Klinik ve ürün bazlı özel indirimler',
reports: 'Raporlar',
reportsSub: 'İş geçmişi, finans ve analiz',
aiAssistant: 'AI Asistan',
aiAssistantSub: 'İşler ve finans hakkında soru sor',
signOut: 'Çıkış Yap',
signOutTitle: 'Çıkış Yap',
signOutConfirm: 'Hesabınızdan çıkış yapmak istiyor musunuz?',
editLabInfo: 'Laboratuvar Bilgilerini Düzenle',
editClinicInfo: 'Klinik Bilgilerini Düzenle',
labNameHint: 'Laboratuvar adını girin',
clinicNameHint: 'Klinik adını girin',
appLanguage: 'Uygulama Dili',
languageSelection: 'Dil Seçimi',
currencySelection: 'Para Birimi Seçimi',
languageTurkish: 'Türkçe',
languageEnglish: 'English',
languageRussian: 'Русский',
languageArabic: 'العربية',
languageGerman: 'Deutsch',
type: 'Tür',
roleOwner: 'Sahibi',
roleAdmin: 'Yönetici',
roleTechnician: 'Teknisyen',
roleDelivery: 'Teslimat Elemanı',
roleFinance: 'Finans Elemanı',
roleDoctor: 'Hekim',
roleMember: 'Üye',
tenantKindClinic: 'Klinik',
tenantKindLab: 'Laboratuvar',
signInWelcome: 'Tekrar hoş geldiniz',
signInSubtitle: 'Hesabınıza giriş yapın',
emailAddress: 'E-posta adresi',
password: 'Şifre',
emailRequired: 'E-posta gereklidir',
passwordRequired: 'Şifre gereklidir',
signIn: 'Giriş Yap',
noAccount: 'Hesabın yok mu?',
signUp: 'Kayıt Ol',
signInHeadline: 'Dental Lab\nYönetimini\nBasitleştirin.',
signInTagline: 'İş takibi, klinik bağlantısı ve\ngerçek zamanlı durum izleme.',
footerCopyright: '© 2026 kovakyazilim.com · Dental Lab Sistemi',
signUpTitle: 'Hesap Oluştur',
signUpSubtitle: 'DLS\'e kaydolun',
firstName: 'Ad',
lastName: 'Soyad',
firstNameHint: 'Adınızı girin',
lastNameHint: 'Soyadınızı girin',
emailHint: 'E-posta adresinizi girin',
passwordHint: 'Şifrenizi girin',
confirmPassword: 'Şifre Tekrar',
confirmPasswordHint: 'Şifrenizi tekrar girin',
passwordMismatch: 'Şifreler eşleşmiyor',
alreadyHaveAccount: 'Zaten hesabın var mı?',
finance: 'Finans',
pendingReceivable: 'Bekleyen Alacak',
collected: 'Tahsil Edilen',
pending: 'Bekleyen',
sortNewest: 'Yeniden Eskiye',
sortAmountDesc: 'Tutara Göre (Büyükten Küçüğe)',
sortAmountAsc: 'Tutara Göre (Küçükten Büyüğe)',
noPendingEntries: 'Bekleyen alacak yok',
noPaidEntries: 'Tahsil edilen kayıt yok',
laboratoryCategory: 'LABORATUVAR',
clinicCategory: 'KLİNİK',
jobsTitle: 'İşler',
dashboardTitle: 'Özet',
productsTitle: 'Ürünler',
patientsTitle: 'Hastalar',
currencyTRY: 'Türk Lirası (₺)',
currencyUSD: 'US Dollar (\$)',
currencyEUR: 'Euro (€)',
currencyGBP: 'British Pound (£)',
currencyAED: 'UAE Dirham (د.إ)',
);
// ── English ───────────────────────────────────────────────────────────────
static const en = AppStrings(
cancel: 'Cancel',
save: 'Save',
edit: 'Edit',
preferences: 'Preferences',
close: 'Close',
confirm: 'Confirm',
retry: 'Retry',
errorPrefix: 'Error',
sort: 'Sort',
settings: 'Settings',
userInfo: 'User Information',
labInfo: 'Laboratory Information',
clinicInfo: 'Clinic Information',
labName: 'Laboratory Name',
clinicName: 'Clinic Name',
currency: 'Currency',
status: 'Status',
active: 'Active',
role: 'Role',
connections: 'Connections',
clinicConnections: 'Clinic Connections',
clinicConnectionsSub: 'Connected clinics and requests',
labConnections: 'Laboratory Connections',
labConnectionsSub: 'Connected labs and requests',
otherMemberships: 'Other Memberships',
management: 'Management',
team: 'Team',
teamSub: 'Members and invitations',
discounts: 'Discounts',
discountsSub: 'Custom discounts by clinic and product',
reports: 'Reports',
reportsSub: 'Job history, finance and analytics',
aiAssistant: 'AI Assistant',
aiAssistantSub: 'Ask about jobs and finance',
signOut: 'Sign Out',
signOutTitle: 'Sign Out',
signOutConfirm: 'Are you sure you want to sign out?',
editLabInfo: 'Edit Laboratory Info',
editClinicInfo: 'Edit Clinic Info',
labNameHint: 'Enter laboratory name',
clinicNameHint: 'Enter clinic name',
appLanguage: 'App Language',
languageSelection: 'Language Selection',
currencySelection: 'Currency Selection',
languageTurkish: 'Türkçe',
languageEnglish: 'English',
languageRussian: 'Русский',
languageArabic: 'العربية',
languageGerman: 'Deutsch',
type: 'Type',
roleOwner: 'Owner',
roleAdmin: 'Admin',
roleTechnician: 'Technician',
roleDelivery: 'Delivery Staff',
roleFinance: 'Finance Staff',
roleDoctor: 'Doctor',
roleMember: 'Member',
tenantKindClinic: 'Clinic',
tenantKindLab: 'Laboratory',
signInWelcome: 'Welcome back',
signInSubtitle: 'Sign in to your account',
emailAddress: 'Email address',
password: 'Password',
emailRequired: 'Email is required',
passwordRequired: 'Password is required',
signIn: 'Sign In',
noAccount: "Don't have an account?",
signUp: 'Sign Up',
signInHeadline: 'Simplify Dental\nLab Management.',
signInTagline: 'Job tracking, clinic connections, and\nreal-time status monitoring.',
footerCopyright: '© 2026 kovakyazilim.com · Dental Lab System',
signUpTitle: 'Create Account',
signUpSubtitle: 'Sign up for DLS',
firstName: 'First Name',
lastName: 'Last Name',
firstNameHint: 'Enter your first name',
lastNameHint: 'Enter your last name',
emailHint: 'Enter your email address',
passwordHint: 'Enter your password',
confirmPassword: 'Confirm Password',
confirmPasswordHint: 'Re-enter your password',
passwordMismatch: 'Passwords do not match',
alreadyHaveAccount: 'Already have an account?',
finance: 'Finance',
pendingReceivable: 'Outstanding Balance',
collected: 'Collected',
pending: 'Pending',
sortNewest: 'Newest to Oldest',
sortAmountDesc: 'By Amount (High to Low)',
sortAmountAsc: 'By Amount (Low to High)',
noPendingEntries: 'No outstanding balance',
noPaidEntries: 'No collected records',
laboratoryCategory: 'LABORATORY',
clinicCategory: 'CLINIC',
jobsTitle: 'Jobs',
dashboardTitle: 'Overview',
productsTitle: 'Products',
patientsTitle: 'Patients',
currencyTRY: 'Turkish Lira (₺)',
currencyUSD: 'US Dollar (\$)',
currencyEUR: 'Euro (€)',
currencyGBP: 'British Pound (£)',
currencyAED: 'UAE Dirham (د.إ)',
);
// ── Russian ───────────────────────────────────────────────────────────────
static const ru = AppStrings(
cancel: 'Отмена',
save: 'Сохранить',
edit: 'Изменить',
preferences: 'Предпочтения',
close: 'Закрыть',
confirm: 'Подтвердить',
retry: 'Повторить',
errorPrefix: 'Ошибка',
sort: 'Сортировка',
settings: 'Настройки',
userInfo: 'Информация о пользователе',
labInfo: 'Информация о лаборатории',
clinicInfo: 'Информация о клинике',
labName: 'Название лаборатории',
clinicName: 'Название клиники',
currency: 'Валюта',
status: 'Статус',
active: 'Активный',
role: 'Роль',
connections: 'Подключения',
clinicConnections: 'Подключения к клиникам',
clinicConnectionsSub: 'Подключённые клиники и запросы',
labConnections: 'Подключения к лабораториям',
labConnectionsSub: 'Подключённые лаборатории и запросы',
otherMemberships: 'Другие членства',
management: 'Управление',
team: 'Команда',
teamSub: 'Участники и приглашения',
discounts: 'Скидки',
discountsSub: 'Специальные скидки по клинике и продукту',
reports: 'Отчёты',
reportsSub: 'История заказов, финансы и аналитика',
aiAssistant: 'ИИ-ассистент',
aiAssistantSub: 'Задавайте вопросы о заказах и финансах',
signOut: 'Выйти',
signOutTitle: 'Выйти',
signOutConfirm: 'Вы уверены, что хотите выйти из аккаунта?',
editLabInfo: 'Редактировать информацию о лаборатории',
editClinicInfo: 'Редактировать информацию о клинике',
labNameHint: 'Введите название лаборатории',
clinicNameHint: 'Введите название клиники',
appLanguage: 'Язык приложения',
languageSelection: 'Выбор языка',
currencySelection: 'Выбор валюты',
languageTurkish: 'Türkçe',
languageEnglish: 'English',
languageRussian: 'Русский',
languageArabic: 'العربية',
languageGerman: 'Deutsch',
type: 'Тип',
roleOwner: 'Владелец',
roleAdmin: 'Администратор',
roleTechnician: 'Техник',
roleDelivery: 'Сотрудник доставки',
roleFinance: 'Финансовый сотрудник',
roleDoctor: 'Врач',
roleMember: 'Участник',
tenantKindClinic: 'Клиника',
tenantKindLab: 'Лаборатория',
signInWelcome: 'Добро пожаловать',
signInSubtitle: 'Войдите в свой аккаунт',
emailAddress: 'Адрес эл. почты',
password: 'Пароль',
emailRequired: 'Эл. почта обязательна',
passwordRequired: 'Пароль обязателен',
signIn: 'Войти',
noAccount: 'Нет аккаунта?',
signUp: 'Зарегистрироваться',
signInHeadline: 'Упростите управление\nзубной лабораторией.',
signInTagline: 'Отслеживание заказов, связь с клиниками\nи мониторинг в реальном времени.',
footerCopyright: '© 2026 kovakyazilim.com · Dental Lab System',
signUpTitle: 'Создать аккаунт',
signUpSubtitle: 'Зарегистрироваться в DLS',
firstName: 'Имя',
lastName: 'Фамилия',
firstNameHint: 'Введите ваше имя',
lastNameHint: 'Введите вашу фамилию',
emailHint: 'Введите адрес эл. почты',
passwordHint: 'Введите ваш пароль',
confirmPassword: 'Подтверждение пароля',
confirmPasswordHint: 'Повторите ваш пароль',
passwordMismatch: 'Пароли не совпадают',
alreadyHaveAccount: 'Уже есть аккаунт?',
finance: 'Финансы',
pendingReceivable: 'Задолженность',
collected: 'Получено',
pending: 'Ожидающие',
sortNewest: 'Сначала новые',
sortAmountDesc: 'По сумме (убывание)',
sortAmountAsc: 'По сумме (возрастание)',
noPendingEntries: 'Нет задолженностей',
noPaidEntries: 'Нет оплаченных записей',
laboratoryCategory: 'ЛАБОРАТОРИЯ',
clinicCategory: 'КЛИНИКА',
jobsTitle: 'Заказы',
dashboardTitle: 'Обзор',
productsTitle: 'Продукты',
patientsTitle: 'Пациенты',
currencyTRY: 'Турецкая лира (₺)',
currencyUSD: 'Доллар США (\$)',
currencyEUR: 'Евро (€)',
currencyGBP: 'Британский фунт (£)',
currencyAED: 'Дирхам ОАЭ (د.إ)',
);
// ── Arabic ────────────────────────────────────────────────────────────────
static const ar = AppStrings(
cancel: 'إلغاء',
save: 'حفظ',
edit: 'تعديل',
preferences: 'التفضيلات',
close: 'إغلاق',
confirm: 'تأكيد',
retry: 'إعادة المحاولة',
errorPrefix: 'خطأ',
sort: 'ترتيب',
settings: 'الإعدادات',
userInfo: 'معلومات المستخدم',
labInfo: 'معلومات المختبر',
clinicInfo: 'معلومات العيادة',
labName: 'اسم المختبر',
clinicName: 'اسم العيادة',
currency: 'العملة',
status: 'الحالة',
active: 'نشط',
role: 'الدور',
connections: 'الاتصالات',
clinicConnections: 'اتصالات العيادة',
clinicConnectionsSub: 'العيادات المتصلة والطلبات',
labConnections: 'اتصالات المختبر',
labConnectionsSub: 'المختبرات المتصلة والطلبات',
otherMemberships: 'عضويات أخرى',
management: 'الإدارة',
team: 'الفريق',
teamSub: 'الأعضاء والدعوات',
discounts: 'الخصومات',
discountsSub: 'خصومات مخصصة حسب العيادة والمنتج',
reports: 'التقارير',
reportsSub: 'تاريخ الأعمال والمالية والتحليلات',
aiAssistant: 'مساعد الذكاء الاصطناعي',
aiAssistantSub: 'اسأل عن الأعمال والمالية',
signOut: 'تسجيل الخروج',
signOutTitle: 'تسجيل الخروج',
signOutConfirm: 'هل أنت متأكد من تسجيل الخروج؟',
editLabInfo: 'تعديل معلومات المختبر',
editClinicInfo: 'تعديل معلومات العيادة',
labNameHint: 'أدخل اسم المختبر',
clinicNameHint: 'أدخل اسم العيادة',
appLanguage: 'لغة التطبيق',
languageSelection: 'اختيار اللغة',
currencySelection: 'اختيار العملة',
languageTurkish: 'Türkçe',
languageEnglish: 'English',
languageRussian: 'Русский',
languageArabic: 'العربية',
languageGerman: 'Deutsch',
type: 'النوع',
roleOwner: 'المالك',
roleAdmin: 'المسؤول',
roleTechnician: 'فني',
roleDelivery: 'موظف توصيل',
roleFinance: 'موظف مالي',
roleDoctor: 'طبيب',
roleMember: 'عضو',
tenantKindClinic: 'عيادة',
tenantKindLab: 'مختبر',
signInWelcome: 'مرحباً بعودتك',
signInSubtitle: 'سجّل دخولك إلى حسابك',
emailAddress: 'البريد الإلكتروني',
password: 'كلمة المرور',
emailRequired: 'البريد الإلكتروني مطلوب',
passwordRequired: 'كلمة المرور مطلوبة',
signIn: 'تسجيل الدخول',
noAccount: 'ليس لديك حساب؟',
signUp: 'إنشاء حساب',
signInHeadline: 'بسّط إدارة\nمختبر الأسنان.',
signInTagline: 'تتبع الأعمال والتواصل مع العيادات\nومراقبة الحالة في الوقت الفعلي.',
footerCopyright: '© 2026 kovakyazilim.com · Dental Lab System',
signUpTitle: 'إنشاء حساب',
signUpSubtitle: 'سجّل في DLS',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
firstNameHint: 'أدخل اسمك الأول',
lastNameHint: 'أدخل اسم عائلتك',
emailHint: 'أدخل بريدك الإلكتروني',
passwordHint: 'أدخل كلمة مرورك',
confirmPassword: 'تأكيد كلمة المرور',
confirmPasswordHint: 'أعد إدخال كلمة مرورك',
passwordMismatch: 'كلمتا المرور غير متطابقتين',
alreadyHaveAccount: 'لديك حساب بالفعل؟',
finance: 'المالية',
pendingReceivable: 'المستحقات',
collected: 'المحصّل',
pending: 'معلّق',
sortNewest: 'الأحدث أولاً',
sortAmountDesc: 'حسب المبلغ (تنازلي)',
sortAmountAsc: 'حسب المبلغ (تصاعدي)',
noPendingEntries: 'لا توجد مستحقات',
noPaidEntries: 'لا توجد سجلات محصّلة',
laboratoryCategory: 'المختبر',
clinicCategory: 'العيادة',
jobsTitle: 'الأعمال',
dashboardTitle: 'نظرة عامة',
productsTitle: 'المنتجات',
patientsTitle: 'المرضى',
currencyTRY: 'ليرة تركية (₺)',
currencyUSD: 'دولار أمريكي (\$)',
currencyEUR: 'يورو (€)',
currencyGBP: 'جنيه إسترليني (£)',
currencyAED: 'درهم إماراتي (د.إ)',
);
// ── German ────────────────────────────────────────────────────────────────
static const de = AppStrings(
cancel: 'Abbrechen',
save: 'Speichern',
edit: 'Bearbeiten',
preferences: 'Einstellungen',
close: 'Schließen',
confirm: 'Bestätigen',
retry: 'Wiederholen',
errorPrefix: 'Fehler',
sort: 'Sortieren',
settings: 'Einstellungen',
userInfo: 'Benutzerinformationen',
labInfo: 'Laborinformationen',
clinicInfo: 'Klinikinformationen',
labName: 'Laborname',
clinicName: 'Klinikname',
currency: 'Währung',
status: 'Status',
active: 'Aktiv',
role: 'Rolle',
connections: 'Verbindungen',
clinicConnections: 'Klinikverbindungen',
clinicConnectionsSub: 'Verbundene Kliniken und Anfragen',
labConnections: 'Laborverbindungen',
labConnectionsSub: 'Verbundene Labore und Anfragen',
otherMemberships: 'Andere Mitgliedschaften',
management: 'Verwaltung',
team: 'Team',
teamSub: 'Mitglieder und Einladungen',
discounts: 'Rabatte',
discountsSub: 'Individuelle Rabatte nach Klinik und Produkt',
reports: 'Berichte',
reportsSub: 'Auftragsverlauf, Finanzen und Analysen',
aiAssistant: 'KI-Assistent',
aiAssistantSub: 'Fragen zu Aufträgen und Finanzen stellen',
signOut: 'Abmelden',
signOutTitle: 'Abmelden',
signOutConfirm: 'Sind Sie sicher, dass Sie sich abmelden möchten?',
editLabInfo: 'Laborinformationen bearbeiten',
editClinicInfo: 'Klinikinformationen bearbeiten',
labNameHint: 'Laborname eingeben',
clinicNameHint: 'Klinikname eingeben',
appLanguage: 'App-Sprache',
languageSelection: 'Sprachauswahl',
currencySelection: 'Währungsauswahl',
languageTurkish: 'Türkçe',
languageEnglish: 'English',
languageRussian: 'Русский',
languageArabic: 'العربية',
languageGerman: 'Deutsch',
type: 'Typ',
roleOwner: 'Inhaber',
roleAdmin: 'Administrator',
roleTechnician: 'Techniker',
roleDelivery: 'Liefermitarbeiter',
roleFinance: 'Finanzmitarbeiter',
roleDoctor: 'Arzt',
roleMember: 'Mitglied',
tenantKindClinic: 'Klinik',
tenantKindLab: 'Labor',
signInWelcome: 'Willkommen zurück',
signInSubtitle: 'Melden Sie sich in Ihrem Konto an',
emailAddress: 'E-Mail-Adresse',
password: 'Passwort',
emailRequired: 'E-Mail ist erforderlich',
passwordRequired: 'Passwort ist erforderlich',
signIn: 'Anmelden',
noAccount: 'Kein Konto?',
signUp: 'Registrieren',
signInHeadline: 'Dental-Labor-Verwaltung\nvereinfachen.',
signInTagline: 'Auftragsverfolgung, Klinikverbindungen\nund Echtzeitüberwachung.',
footerCopyright: '© 2026 kovakyazilim.com · Dental Lab System',
signUpTitle: 'Konto erstellen',
signUpSubtitle: 'Bei DLS registrieren',
firstName: 'Vorname',
lastName: 'Nachname',
firstNameHint: 'Vornamen eingeben',
lastNameHint: 'Nachnamen eingeben',
emailHint: 'E-Mail-Adresse eingeben',
passwordHint: 'Passwort eingeben',
confirmPassword: 'Passwort bestätigen',
confirmPasswordHint: 'Passwort erneut eingeben',
passwordMismatch: 'Passwörter stimmen nicht überein',
alreadyHaveAccount: 'Haben Sie bereits ein Konto?',
finance: 'Finanzen',
pendingReceivable: 'Ausstehende Forderungen',
collected: 'Eingezogen',
pending: 'Ausstehend',
sortNewest: 'Neueste zuerst',
sortAmountDesc: 'Nach Betrag (absteigend)',
sortAmountAsc: 'Nach Betrag (aufsteigend)',
noPendingEntries: 'Keine ausstehenden Forderungen',
noPaidEntries: 'Keine eingezogenen Einträge',
laboratoryCategory: 'LABOR',
clinicCategory: 'KLINIK',
jobsTitle: 'Aufträge',
dashboardTitle: 'Übersicht',
productsTitle: 'Produkte',
patientsTitle: 'Patienten',
currencyTRY: 'Türkische Lira (₺)',
currencyUSD: 'US-Dollar (\$)',
currencyEUR: 'Euro (€)',
currencyGBP: 'Britisches Pfund (£)',
currencyAED: 'VAE-Dirham (د.إ)',
);
}
+195
View File
@@ -0,0 +1,195 @@
import 'package:flutter/widgets.dart' show Locale;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pocketbase/pocketbase.dart';
import '../auth/auth_repository.dart';
import '../services/notification_service.dart';
import '../../models/tenant.dart';
import '../../models/user_profile.dart';
import 'locale_provider.dart';
class AuthState {
const AuthState({
this.profile,
this.activeTenant,
this.memberships = const [],
this.isLoading = true,
this.error,
});
final UserProfile? profile;
final TenantMembership? activeTenant;
final List<TenantMembership> memberships;
final bool isLoading;
final String? error;
bool get isAuthenticated => profile != null;
AuthState copyWith({
UserProfile? profile,
TenantMembership? activeTenant,
List<TenantMembership>? memberships,
bool? isLoading,
String? error,
bool clearError = false,
}) =>
AuthState(
profile: profile ?? this.profile,
activeTenant: activeTenant ?? this.activeTenant,
memberships: memberships ?? this.memberships,
isLoading: isLoading ?? this.isLoading,
error: clearError ? null : (error ?? this.error),
);
}
class AuthNotifier extends StateNotifier<AuthState> {
AuthNotifier({this.onLocaleLoaded}) : super(const AuthState()) {
_init();
}
final void Function(String languageCode)? onLocaleLoaded;
final _repo = AuthRepository.instance;
Future<void> _init() async {
final loggedIn = await _repo.isLoggedIn();
if (!loggedIn) {
state = const AuthState(isLoading: false);
return;
}
try {
final result = await _repo.refreshSession();
state = AuthState(
profile: result.user,
memberships: result.tenants,
activeTenant:
result.tenants.isEmpty ? null : result.tenants.first,
isLoading: false,
);
final isLab = result.tenants.isNotEmpty && result.tenants.first.tenant.isLab;
NotificationService.loginUser(result.user.id, isLab: isLab);
_applyLocale(result.user.preferredLanguage);
} catch (_) {
state = const AuthState(isLoading: false);
}
}
void _applyLocale(String? code) {
if (code != null && code.isNotEmpty) {
onLocaleLoaded?.call(code);
}
}
Future<void> signIn(String email, String password) async {
state = state.copyWith(isLoading: true, clearError: true);
try {
final result = await _repo.login(email, password);
state = AuthState(
profile: result.user,
memberships: result.tenants,
activeTenant:
result.tenants.isEmpty ? null : result.tenants.first,
isLoading: false,
);
final isLab = result.tenants.isNotEmpty && result.tenants.first.tenant.isLab;
NotificationService.loginUser(result.user.id, isLab: isLab);
_applyLocale(result.user.preferredLanguage);
} catch (e) {
state = state.copyWith(isLoading: false, error: _parseError(e));
}
}
Future<void> register({
required String email,
required String password,
String? firstName,
String? lastName,
}) async {
state = state.copyWith(isLoading: true, clearError: true);
try {
final result = await _repo.register(
email: email,
password: password,
firstName: firstName,
lastName: lastName,
);
state = AuthState(
profile: result.user,
memberships: result.tenants,
activeTenant:
result.tenants.isEmpty ? null : result.tenants.first,
isLoading: false,
);
} catch (e) {
state = state.copyWith(isLoading: false, error: _parseError(e));
rethrow;
}
}
Future<void> signOut() async {
await _repo.logout();
await NotificationService.logoutUser();
state = const AuthState(isLoading: false);
}
void setActiveTenant(TenantMembership membership) {
state = state.copyWith(activeTenant: membership);
}
Future<void> refresh() async {
try {
final result = await _repo.refreshSession();
final currentId = state.activeTenant?.tenant.id;
final newActive = currentId != null
? result.tenants.firstWhere(
(m) => m.tenant.id == currentId,
orElse: () => result.tenants.isNotEmpty
? result.tenants.first
: state.activeTenant!,
)
: (result.tenants.isNotEmpty ? result.tenants.first : null);
state = state.copyWith(
profile: result.user,
memberships: result.tenants,
activeTenant: newActive,
);
} catch (_) {}
}
Future<void> updateLanguage(String languageCode) async {
final userId = state.profile?.id;
if (userId == null) return;
await _repo.updateUserLanguage(userId, languageCode);
}
Future<void> updateTenantInfo({
required String tenantId,
required String companyName,
String? defaultCurrency,
}) async {
await _repo.updateTenant(
tenantId,
companyName: companyName,
defaultCurrency: defaultCurrency,
);
await refresh();
}
String _parseError(Object e) {
if (e is ClientException) {
final code = e.statusCode;
if (code == 400 || code == 401 || code == 403) {
return 'E-posta veya şifre hatalı.';
}
final msg = e.response['message'] as String? ?? '';
if (msg.isNotEmpty) return msg;
}
return 'Bağlantı hatası. Lütfen tekrar deneyin.';
}
}
final authProvider =
StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier(
onLocaleLoaded: (code) =>
ref.read(localeProvider.notifier).setLocale(Locale(code)),
);
});
+39
View File
@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../l10n/app_strings.dart';
const _kLocaleKey = 'app_locale';
class LocaleNotifier extends StateNotifier<Locale> {
LocaleNotifier(Locale initial) : super(initial);
Future<void> setLocale(Locale locale) async {
state = locale;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kLocaleKey, locale.languageCode);
}
static Future<Locale> load() async {
final prefs = await SharedPreferences.getInstance();
final code = prefs.getString(_kLocaleKey) ?? 'tr';
return Locale(code);
}
}
final localeProvider = StateNotifierProvider<LocaleNotifier, Locale>(
(ref) => LocaleNotifier(const Locale('tr')),
);
final stringsProvider = Provider<AppStrings>((ref) {
final locale = ref.watch(localeProvider);
return AppStrings.of(locale.languageCode);
});
const supportedLocales = [
Locale('tr'),
Locale('en'),
Locale('ru'),
Locale('ar'),
Locale('de'),
];
+496
View File
@@ -0,0 +1,496 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../theme/app_theme.dart';
import '../widgets/tooth_logo.dart';
import '../providers/auth_provider.dart';
import '../../models/tenant.dart';
import '../../features/auth/sign_in_screen.dart';
import '../../features/auth/sign_up_screen.dart';
import '../../features/auth/onboarding_screen.dart';
import '../../features/clinic/dashboard/clinic_dashboard_screen.dart';
import '../../features/clinic/jobs/clinic_jobs_screen.dart';
import '../../features/clinic/jobs/clinic_job_detail_screen.dart';
import '../../features/clinic/jobs/new_job_screen.dart';
import '../../features/clinic/patients/clinic_patients_screen.dart';
import '../../features/clinic/patients/clinic_patient_detail_screen.dart';
import '../../features/clinic/connections/clinic_connections_screen.dart';
import '../../features/clinic/finance/clinic_finance_screen.dart';
import '../../features/clinic/settings/clinic_settings_screen.dart';
import '../../features/lab/dashboard/lab_dashboard_screen.dart';
import '../../features/lab/jobs/lab_jobs_inbound_screen.dart';
import '../../features/lab/jobs/lab_all_jobs_screen.dart';
import '../../features/lab/jobs/lab_job_detail_screen.dart';
import '../../features/lab/products/lab_products_screen.dart';
import '../../features/lab/connections/lab_connections_screen.dart';
import '../../features/lab/finance/lab_finance_screen.dart';
import '../../features/lab/settings/lab_settings_screen.dart';
import '../../features/shared/reports_screen.dart';
import '../../features/shared/ai_chat_screen.dart';
import '../../features/lab/discounts/discounts_screen.dart';
import '../../features/lab/connections/connection_detail_screen.dart';
import '../../models/connection.dart';
// Auth routes
const routeSignIn = '/sign-in';
const routeSignUp = '/sign-up';
const routeOnboarding = '/onboarding';
// Clinic routes
const routeClinicDashboard = '/clinic/dashboard';
const routeClinicJobs = '/clinic/jobs';
const routeClinicJobDetail = '/clinic/jobs/:jobId';
const routeClinicJobNew = '/clinic/jobs/new';
const routeClinicPatients = '/clinic/patients';
const routeClinicPatientDetail = '/clinic/patients/:patientId';
const routeClinicConnections = '/clinic/connections';
const routeClinicFinance = '/clinic/finance';
const routeClinicSettings = '/clinic/settings';
const routeClinicReports = '/clinic/reports';
const routeClinicAi = '/clinic/ai';
// Lab routes
const routeLabDashboard = '/lab/dashboard';
const routeLabJobsInbound = '/lab/jobs/inbound';
const routeLabJobsAll = '/lab/jobs';
const routeLabJobDetail = '/lab/jobs/:jobId';
const routeLabProducts = '/lab/products';
const routeLabConnections = '/lab/connections';
const routeLabFinance = '/lab/finance';
const routeLabSettings = '/lab/settings';
const routeLabReports = '/lab/reports';
const routeLabAi = '/lab/ai';
const routeLabDiscounts = '/lab/discounts';
List<RouteBase> buildRoutes() => [
GoRoute(path: routeSignIn, builder: (_, __) => const SignInScreen()),
GoRoute(path: routeSignUp, builder: (_, __) => const SignUpScreen()),
GoRoute(path: routeOnboarding, builder: (_, __) => const OnboardingScreen()),
// ── Clinic shell ──────────────────────────────────────────────────────
ShellRoute(
builder: (context, state, child) => _ClinicShell(child: child),
routes: [
GoRoute(path: routeClinicDashboard, builder: (_, __) => const ClinicDashboardScreen()),
GoRoute(
path: routeClinicJobs,
builder: (_, __) => const ClinicJobsScreen(),
routes: [
GoRoute(path: 'new', builder: (_, __) => const NewJobScreen()),
GoRoute(
path: ':jobId',
builder: (_, s) => ClinicJobDetailScreen(jobId: s.pathParameters['jobId']!),
),
],
),
GoRoute(
path: routeClinicPatients,
builder: (_, __) => const ClinicPatientsScreen(),
routes: [
GoRoute(
path: ':patientId',
builder: (_, s) => ClinicPatientDetailScreen(patientId: s.pathParameters['patientId']!),
),
],
),
GoRoute(path: routeClinicConnections, builder: (_, __) => const ClinicConnectionsScreen()),
GoRoute(path: routeClinicFinance, builder: (_, __) => const ClinicFinanceScreen()),
GoRoute(path: routeClinicSettings, builder: (_, __) => const ClinicSettingsScreen()),
GoRoute(path: routeClinicReports, builder: (_, __) => const ReportsScreen()),
GoRoute(path: routeClinicAi, builder: (_, __) => const AiChatScreen()),
],
),
// ── Lab shell ─────────────────────────────────────────────────────────
ShellRoute(
builder: (context, state, child) => _LabShell(child: child),
routes: [
GoRoute(path: routeLabDashboard, builder: (_, __) => const LabDashboardScreen()),
GoRoute(path: routeLabJobsInbound, builder: (_, __) => const LabJobsInboundScreen()),
GoRoute(
path: routeLabJobsAll,
builder: (_, __) => const LabAllJobsScreen(),
routes: [
GoRoute(
path: ':jobId',
builder: (_, s) => LabJobDetailScreen(jobId: s.pathParameters['jobId']!),
),
],
),
GoRoute(path: routeLabProducts, builder: (_, __) => const LabProductsScreen()),
GoRoute(
path: routeLabConnections,
builder: (_, __) => const LabConnectionsScreen(),
routes: [
GoRoute(
path: ':connectionId/detail',
builder: (_, s) {
final extra = s.extra as Map<String, dynamic>?;
final connection = extra?['connection'] as Connection?;
final labTenantId = extra?['labTenantId'] as String? ?? '';
if (connection == null) {
return const Scaffold(
body: Center(child: Text('Bağlantı bulunamadı')),
);
}
return ConnectionDetailScreen(
connection: connection, labTenantId: labTenantId);
},
),
],
),
GoRoute(path: routeLabDiscounts, builder: (_, __) => const DiscountsScreen()),
GoRoute(path: routeLabFinance, builder: (_, __) => const LabFinanceScreen()),
GoRoute(path: routeLabSettings, builder: (_, __) => const LabSettingsScreen()),
GoRoute(path: routeLabReports, builder: (_, __) => const ReportsScreen()),
GoRoute(path: routeLabAi, builder: (_, __) => const AiChatScreen()),
],
),
];
// ── Nav item descriptor ───────────────────────────────────────────────────────
class _NavItem {
const _NavItem({
required this.route,
required this.icon,
required this.selectedIcon,
required this.label,
required this.visible,
});
final String route;
final Icon icon;
final Icon selectedIcon;
final String label;
final bool Function(TenantMembership?) visible;
}
// ── Clinic shell ──────────────────────────────────────────────────────────────
class _ClinicShell extends ConsumerStatefulWidget {
const _ClinicShell({required this.child});
final Widget child;
@override
ConsumerState<_ClinicShell> createState() => _ClinicShellState();
}
class _ClinicShellState extends ConsumerState<_ClinicShell> {
int _index = 0;
static final _allItems = [
_NavItem(route: routeClinicDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: 'Ana Sayfa', visible: (_) => true),
_NavItem(route: routeClinicJobs, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: 'İşler', visible: (m) => m?.showJobs ?? true),
_NavItem(route: routeClinicPatients, icon: const Icon(Icons.people_outline_rounded), selectedIcon: const Icon(Icons.people_rounded), label: 'Hastalar', visible: (m) => m?.showPatients ?? true),
_NavItem(route: routeClinicFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: 'Finans', visible: (m) => m?.showFinance ?? true),
_NavItem(route: routeClinicSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: 'Ayarlar', visible: (_) => true),
];
@override
Widget build(BuildContext context) {
final membership = ref.watch(authProvider).activeTenant;
final items = _allItems.where((it) => it.visible(membership)).toList();
final clampedIndex = _index.clamp(0, items.length - 1);
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
void onTap(int i) {
setState(() => _index = i);
context.go(items[i].route);
}
if (isDesktop) {
return Scaffold(
backgroundColor: AppColors.background,
body: Row(
children: [
_DesktopSidebar(destinations: items, selectedIndex: clampedIndex, onTap: onTap),
Expanded(child: widget.child),
],
),
);
}
return Scaffold(
body: widget.child,
bottomNavigationBar: NavigationBar(
selectedIndex: clampedIndex,
onDestinationSelected: onTap,
destinations: [
for (final it in items)
Semantics(
label: it.label,
button: true,
child: NavigationDestination(icon: it.icon, selectedIcon: it.selectedIcon, label: it.label),
),
],
),
);
}
}
// ── Lab shell ─────────────────────────────────────────────────────────────────
class _LabShell extends ConsumerStatefulWidget {
const _LabShell({required this.child});
final Widget child;
@override
ConsumerState<_LabShell> createState() => _LabShellState();
}
class _LabShellState extends ConsumerState<_LabShell> {
int _index = 0;
static final _allItems = [
_NavItem(route: routeLabDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: 'Ana Sayfa', visible: (_) => true),
_NavItem(route: routeLabJobsAll, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: 'İşler', visible: (m) => m?.showJobs ?? true),
_NavItem(route: routeLabProducts, icon: const Icon(Icons.inventory_2_outlined), selectedIcon: const Icon(Icons.inventory_2_rounded), label: 'Ürünler', visible: (m) => m?.showProducts ?? true),
_NavItem(route: routeLabFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: 'Finans', visible: (m) => m?.showFinance ?? true),
_NavItem(route: routeLabSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: 'Ayarlar', visible: (_) => true),
];
@override
Widget build(BuildContext context) {
final membership = ref.watch(authProvider).activeTenant;
final items = _allItems.where((it) => it.visible(membership)).toList();
final clampedIndex = _index.clamp(0, items.length - 1);
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
void onTap(int i) {
setState(() => _index = i);
context.go(items[i].route);
}
if (isDesktop) {
return Scaffold(
backgroundColor: AppColors.background,
body: Row(
children: [
_DesktopSidebar(destinations: items, selectedIndex: clampedIndex, onTap: onTap),
Expanded(child: widget.child),
],
),
);
}
return Scaffold(
body: widget.child,
bottomNavigationBar: NavigationBar(
selectedIndex: clampedIndex,
onDestinationSelected: onTap,
destinations: [
for (final it in items)
Semantics(
label: it.label,
button: true,
child: NavigationDestination(icon: it.icon, selectedIcon: it.selectedIcon, label: it.label),
),
],
),
);
}
}
// ── Desktop sidebar ───────────────────────────────────────────────────────────
class _DesktopSidebar extends StatefulWidget {
const _DesktopSidebar({
required this.destinations,
required this.selectedIndex,
required this.onTap,
});
final List<_NavItem> destinations;
final int selectedIndex;
final ValueChanged<int> onTap;
// Must match the toolbarHeight used in desktop SliverAppBar headers
static const double headerHeight = 64;
static const double _openWidth = 220;
static const double _closedWidth = 64;
@override
State<_DesktopSidebar> createState() => _DesktopSidebarState();
}
class _DesktopSidebarState extends State<_DesktopSidebar> {
bool _open = true;
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOut,
width: _open ? _DesktopSidebar._openWidth : _DesktopSidebar._closedWidth,
decoration: const BoxDecoration(
color: AppColors.surface,
border: Border(right: BorderSide(color: AppColors.border)),
boxShadow: [BoxShadow(color: Color(0x08000000), blurRadius: 8, offset: Offset(2, 0))],
),
child: ClipRect(
child: Column(
children: [
// Header
Container(
height: _DesktopSidebar.headerHeight,
decoration: const BoxDecoration(
gradient: LinearGradient(colors: [AppColors.primary, AppColors.accent]),
border: Border(bottom: BorderSide(color: AppColors.border)),
),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(9),
border: Border.all(color: Colors.white.withValues(alpha: 0.25)),
),
child: const Center(child: ToothLogo(size: 18, color: Colors.white)),
),
if (_open) ...[
const SizedBox(width: 10),
const Text(
'DLS',
style: TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.w800, letterSpacing: 1),
),
],
],
),
),
// Nav items
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 8),
for (int i = 0; i < widget.destinations.length; i++)
_SidebarItem(
icon: widget.destinations[i].icon,
selectedIcon: widget.destinations[i].selectedIcon,
label: widget.destinations[i].label,
selected: widget.selectedIndex == i,
open: _open,
onTap: () => widget.onTap(i),
),
const SizedBox(height: 8),
],
),
),
),
// Toggle button
Container(
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: AppColors.border)),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => setState(() => _open = !_open),
child: SizedBox(
height: 48,
child: Row(
mainAxisAlignment: _open ? MainAxisAlignment.start : MainAxisAlignment.center,
children: [
if (_open) const SizedBox(width: 20),
AnimatedRotation(
duration: const Duration(milliseconds: 220),
turns: _open ? 0.5 : 0,
child: const Icon(Icons.chevron_right_rounded, color: AppColors.textMuted, size: 20),
),
if (_open) ...[
const SizedBox(width: 8),
const Text('Daralt', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.textMuted)),
],
],
),
),
),
),
),
],
),
),
);
}
}
// ── Sidebar nav item ──────────────────────────────────────────────────────────
class _SidebarItem extends StatelessWidget {
const _SidebarItem({
required this.icon,
required this.selectedIcon,
required this.label,
required this.selected,
required this.open,
required this.onTap,
});
final Widget icon;
final Widget selectedIcon;
final String label;
final bool selected;
final bool open;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final item = Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Material(
color: selected ? const Color(0xFFDBEAFE) : Colors.transparent,
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: SizedBox(
height: 40,
child: open
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
IconTheme(
data: IconThemeData(
color: selected ? AppColors.primary : AppColors.textSecondary,
size: 20,
),
child: selected ? selectedIcon : icon,
),
const SizedBox(width: 12),
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected ? AppColors.primary : AppColors.textSecondary,
),
),
],
),
)
: Center(
child: IconTheme(
data: IconThemeData(
color: selected ? AppColors.primary : AppColors.textSecondary,
size: 20,
),
child: selected ? selectedIcon : icon,
),
),
),
),
),
);
if (!open) {
return Tooltip(message: label, preferBelow: false, child: item);
}
return item;
}
}
+52
View File
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/auth_provider.dart';
import 'app_router.dart';
// Bridges Riverpod auth state changes to GoRouter's Listenable interface
class _AuthRouterNotifier extends ChangeNotifier {
_AuthRouterNotifier(this._ref) {
_ref.listen<AuthState>(authProvider, (_, __) => notifyListeners());
}
final Ref _ref;
}
final routerProvider = Provider<GoRouter>((ref) {
final notifier = _AuthRouterNotifier(ref);
return GoRouter(
refreshListenable: notifier,
initialLocation: routeSignIn,
redirect: (context, state) {
final auth = ref.read(authProvider);
if (auth.isLoading) return null;
final loc = state.matchedLocation;
final onLoginOrRegister = loc == routeSignIn || loc == routeSignUp;
final onAuthPage = onLoginOrRegister || loc == routeOnboarding;
if (!auth.isAuthenticated) {
return onAuthPage ? null : routeSignIn;
}
// Authenticated but no tenant → onboarding
if (auth.activeTenant == null) {
return loc == routeOnboarding ? null : routeOnboarding;
}
final isLab = auth.activeTenant!.tenant.isLab;
if (onAuthPage) {
return isLab ? routeLabDashboard : routeClinicDashboard;
}
if (isLab && loc.startsWith('/clinic')) return routeLabDashboard;
if (!isLab && loc.startsWith('/lab')) return routeClinicDashboard;
return null;
},
routes: buildRoutes(),
);
});
+171
View File
@@ -0,0 +1,171 @@
import '../../features/shared/job_files_repository.dart';
import '../../features/shared/tenant_team_repository.dart';
import '../../models/job_file.dart';
import '../../models/tenant.dart';
import '../api/pocketbase_client.dart';
// ── Message segments ──────────────────────────────────────────────────────────
sealed class MessageSegment {}
class TextSegment extends MessageSegment {
TextSegment(this.text);
final String text;
}
class ActionSegment extends MessageSegment {
ActionSegment(this.action);
final AiAction action;
}
// ── Action model ──────────────────────────────────────────────────────────────
class AiAction {
const AiAction({
required this.type,
required this.params,
required this.label,
});
final String type;
final Map<String, String> params;
final String label;
bool get isDangerous => type == 'cancel_job';
bool get isFileAction => type == 'job_files';
}
// ── Action outcome ────────────────────────────────────────────────────────────
sealed class ActionOutcome {}
class ActionSuccess extends ActionOutcome {
ActionSuccess(this.message);
final String message;
}
class ActionError extends ActionOutcome {
ActionError(this.error);
final String error;
}
class ActionFiles extends ActionOutcome {
ActionFiles(this.files);
final List<JobFile> files;
}
// ── Parser ────────────────────────────────────────────────────────────────────
List<MessageSegment> parseSegments(String text) {
// Strip code fences wrapping <dls-action> tags that the AI sometimes emits.
// Handles: ```xml\n<dls-action .../>\n``` and ```\n<dls-action .../>\n```
text = text.replaceAllMapped(
RegExp(r'```(?:xml)?\s*\n(\s*<dls-action\s[^>]*/>)\s*\n\s*```'),
(m) => m.group(1)!,
);
// Also handle inline variant: ```xml <dls-action .../> ```
text = text.replaceAllMapped(
RegExp(r'```(?:xml)?\s*(<dls-action\s[^>]*/>)\s*```'),
(m) => m.group(1)!,
);
final pattern = RegExp(r'<dls-action\s([^/]*?)/>', dotAll: true);
final segments = <MessageSegment>[];
int last = 0;
for (final m in pattern.allMatches(text)) {
final before = text.substring(last, m.start).trim();
if (before.isNotEmpty) segments.add(TextSegment(before));
final attrs = _parseAttrs(m.group(1) ?? '');
segments.add(ActionSegment(AiAction(
type: attrs['type'] ?? '',
params: attrs,
label: attrs['label'] ?? attrs['type'] ?? 'İşlem',
)));
last = m.end;
}
final rest = text.substring(last).trim();
if (rest.isNotEmpty) segments.add(TextSegment(rest));
return segments;
}
Map<String, String> _parseAttrs(String s) {
final result = <String, String>{};
for (final m in RegExp(r'(\w+)="([^"]*)"').allMatches(s)) {
result[m.group(1)!] = m.group(2)!;
}
return result;
}
// ── Executor ──────────────────────────────────────────────────────────────────
class AiActionExecutor {
static final _pb = PocketBaseClient.instance.pb;
static Future<ActionOutcome> execute(
AiAction action,
TenantMembership membership,
) async {
try {
return switch (action.type) {
'cancel_job' => await _cancelJob(action.params),
'mark_delivered' => await _markDelivered(action.params),
'job_files' => await _jobFiles(action.params),
'add_member' => await _addMember(action.params, membership),
_ => ActionError('Bilinmeyen işlem türü: ${action.type}'),
};
} catch (e) {
final msg = e.toString();
if (msg.length > 120) return ActionError('Sunucu hatası');
return ActionError(msg);
}
}
static Future<ActionOutcome> _cancelJob(Map<String, String> p) async {
final id = p['job_id'];
if (id == null || id.isEmpty) return ActionError('İş ID bulunamadı.');
await _pb.collection('jobs').update(id, body: {'status': 'cancelled'});
return ActionSuccess('İş başarıyla iptal edildi.');
}
static Future<ActionOutcome> _markDelivered(Map<String, String> p) async {
final id = p['job_id'];
if (id == null || id.isEmpty) return ActionError('İş ID bulunamadı.');
await _pb.collection('jobs').update(id, body: {'status': 'delivered'});
return ActionSuccess('İş teslim edildi olarak işaretlendi.');
}
static Future<ActionOutcome> _jobFiles(Map<String, String> p) async {
final id = p['job_id'];
if (id == null || id.isEmpty) return ActionError('İş ID bulunamadı.');
final files = await JobFilesRepository.instance.listForJob(id);
if (files.isEmpty) return ActionSuccess('Bu iş için henüz dosya yüklenmemiş.');
return ActionFiles(files);
}
static Future<ActionOutcome> _addMember(
Map<String, String> p,
TenantMembership membership,
) async {
final email = p['email'];
final firstName = p['first_name'];
final lastName = p['last_name'] ?? '';
final role = p['role'];
final password = p['password'];
if (email == null || firstName == null || role == null || password == null) {
return ActionError('Eksik bilgi: e-posta, ad, rol ve şifre gerekli.');
}
await TenantTeamRepository.instance.addMember(
tenantId: membership.tenant.id,
email: email,
password: password,
firstName: firstName,
lastName: lastName,
role: TenantMembership.parseRole(role),
);
return ActionSuccess('$firstName $lastName ekibe eklendi.');
}
}
+226
View File
@@ -0,0 +1,226 @@
import 'package:pocketbase/pocketbase.dart';
import '../api/pocketbase_client.dart';
import '../../models/tenant.dart';
class AiContextBuilder {
AiContextBuilder._();
static final instance = AiContextBuilder._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<String> build(TenantMembership membership) async {
final tenant = membership.tenant;
final tenantId = tenant.id;
final isLab = tenant.kind == TenantKind.lab;
final now = DateTime.now();
final dateStr = '${now.day}.${now.month}.${now.year}';
final results = await Future.wait([
_fetchActiveJobs(tenantId, isLab),
_fetchRecentDelivered(tenantId, isLab),
_fetchFinance(tenantId, isLab),
_fetchTeam(tenantId),
]);
final actions = _actionsPrompt(isLab);
return 'Sen DLS (Dental Lab System) uygulamasinin akilli asistanisin.\n'
'${tenant.companyName} adli ${isLab ? 'dental laboratuvarinin' : 'dis kliniginin'} verilerine erisebilirsin.\n'
'Kullanici rolu: ${isLab ? 'LABORATUVAR' : 'KLINIK'}\n'
'\n'
'Tarih: $dateStr\n'
'\n'
'${results[0]}\n'
'\n'
'${results[1]}\n'
'\n'
'${results[2]}\n'
'\n'
'${results[3]}\n'
'\n'
'$actions\n'
'\n'
'Yanit kurallari:\n'
'- Turkce, kisa ve net yaz\n'
'- Sadece yukaridaki verilerden hareketle yorum yap\n'
'- Listelerde madde isareti (- ) kullan\n'
'- Onemli bilgileri **kalin** yaz\n'
'- Aksiyon etiketlerini HERZAMAN metnin sonuna koy\n'
'- ${isLab ? 'Is kodlari icin [ID:...] formatini kullan' : 'Hasta kodlari ve is durumlarini net belirt'}\n';
}
static String _actionsPrompt(bool isLab) {
final buf = StringBuffer();
buf.writeln('## EYLEM YETKILERIN');
buf.writeln('Kullanici bir islem yapmak istediginde asagidaki XML etiketlerini yanita ekle:');
buf.writeln('');
buf.writeln('Is dosyalarini gostermek:');
buf.writeln('<dls-action type="job_files" job_id="JOB_ID" label="AB001 dosyalarini goster"/>');
buf.writeln('');
buf.writeln('Is iptal etmek:');
buf.writeln('<dls-action type="cancel_job" job_id="JOB_ID" label="AB001 isini iptal et"/>');
if (!isLab) {
buf.writeln('');
buf.writeln('Teslim edildi isaretlemek (sadece klinik):');
buf.writeln('<dls-action type="mark_delivered" job_id="JOB_ID" label="AB001 teslim edildi"/>');
}
buf.writeln('');
buf.writeln('Ekip uyesi eklemek (TUM bilgiler alindiktan sonra):');
buf.writeln('<dls-action type="add_member" email="..." first_name="..." last_name="..." role="technician|admin|doctor|delivery|finance|member" password="..." label="Ad Soyad ekle"/>');
buf.writeln('');
buf.writeln('KURALLAR:');
buf.writeln('- Etiketi SADECE kullanici acikca islem istediginde ekle');
buf.writeln('- Sifre sorulursa kullanicidan al, ASLA uydurma');
buf.writeln('- iptal gibi geri alinmaz islemleri acikca belirt');
buf.writeln('- Etiket icindeki job_id degerini yukaridaki is listesinden al');
buf.writeln('- <dls-action> etiketlerini KESINLİKLE kod blogu (```xml veya ```) icine ALMA, duz metin olarak yaz');
return buf.toString();
}
Future<String> _fetchActiveJobs(String tenantId, bool isLab) async {
try {
final tenantField = isLab ? 'lab_tenant_id' : 'clinic_tenant_id';
final counterpartField = isLab ? 'clinic_tenant_id' : 'lab_tenant_id';
final result = await _pb.collection('jobs').getList(
filter: '$tenantField = "$tenantId" && status != "delivered" && status != "cancelled"',
perPage: 60,
sort: '-created',
expand: counterpartField,
);
if (result.items.isEmpty) return '## Aktif Isler\nSu an aktif is yok.';
final counterpartLabel = isLab ? 'Klinik' : 'Lab';
final lines = result.items.map((r) {
final j = r.toJson();
final jobId = j['id'] as String? ?? '';
final expand = j['expand'] as Map<String, dynamic>?;
final counterpart =
(expand?[counterpartField] as Map?)?['company_name'] as String? ?? '-';
final status = _statusTr(j['status'] as String? ?? '');
final prosthetic = j['prosthetic_type'] as String? ?? '-';
final patient = j['patient_code'] as String? ?? '-';
final step = j['current_step'] as String?;
final stepPart = (step != null && step.isNotEmpty) ? ' | Adim: $step' : '';
final due = j['due_date'] as String? ?? '';
final duePart = due.isNotEmpty ? ' | Termin: ${due.substring(0, 10)}' : '';
return '- [ID:$jobId] Hasta: $patient | $prosthetic | $status$stepPart | $counterpartLabel: $counterpart$duePart';
}).join('\n');
return '## Aktif Isler (${result.items.length})\n$lines';
} catch (e) {
return '## Aktif Isler\n(Veri alinamadi: $e)';
}
}
Future<String> _fetchRecentDelivered(String tenantId, bool isLab) async {
try {
final tenantField = isLab ? 'lab_tenant_id' : 'clinic_tenant_id';
final counterpartField = isLab ? 'clinic_tenant_id' : 'lab_tenant_id';
final result = await _pb.collection('jobs').getList(
filter: '$tenantField = "$tenantId" && status = "delivered"',
perPage: 10,
sort: '-updated',
expand: counterpartField,
);
if (result.items.isEmpty) return '## Son Teslim Edilenler\nHenuz teslim edilen is yok.';
final counterpartLabel = isLab ? 'Klinik' : 'Lab';
final lines = result.items.map((r) {
final j = r.toJson();
final jobId = j['id'] as String? ?? '';
final expand = j['expand'] as Map<String, dynamic>?;
final counterpart =
(expand?[counterpartField] as Map?)?['company_name'] as String? ?? '-';
final prosthetic = j['prosthetic_type'] as String? ?? '-';
final patient = j['patient_code'] as String? ?? '-';
final updated = (j['updated'] as String? ?? '');
final datePart = updated.length >= 10 ? updated.substring(0, 10) : '';
return '- [ID:$jobId] Hasta: $patient | $prosthetic | $counterpartLabel: $counterpart${datePart.isNotEmpty ? ' | Tarih: $datePart' : ''}';
}).join('\n');
return '## Son Teslim Edilenler (son 10)\n$lines';
} catch (_) {
return '## Son Teslim Edilenler\n(Veri alinamadi)';
}
}
Future<String> _fetchFinance(String tenantId, bool isLab) async {
try {
final type = isLab ? 'receivable' : 'payable';
final result = await _pb.collection('finance_entries').getList(
filter: 'tenant_id = "$tenantId" && type = "$type"',
perPage: 200,
);
double pending = 0, paid = 0;
for (final r in result.items) {
final j = r.toJson();
final amount = (j['amount'] as num?)?.toDouble() ?? 0;
if (j['status'] == 'pending') {
pending += amount;
} else {
paid += amount;
}
}
final label = isLab ? 'alacak' : 'borc';
return '## Finans\n'
'- Bekleyen $label: ${pending.toStringAsFixed(0)} TL\n'
'- Tahsil edilen: ${paid.toStringAsFixed(0)} TL';
} catch (_) {
return '## Finans\n(Veri alinamadi)';
}
}
Future<String> _fetchTeam(String tenantId) async {
try {
final result = await _pb.collection('tenant_members').getList(
filter: 'tenant_id = "$tenantId"',
expand: 'user_id',
perPage: 50,
);
if (result.items.isEmpty) return '## Ekip\nUye yok.';
final lines = result.items.map((r) {
final j = r.toJson();
final expand = j['expand'] as Map<String, dynamic>?;
final user = expand?['user_id'] as Map<String, dynamic>?;
final first = (user?['first_name'] as String?) ?? '';
final last = (user?['last_name'] as String?) ?? '';
final email = (user?['email'] as String?) ?? '';
final name =
'$first $last'.trim().isNotEmpty ? '$first $last'.trim() : email;
final role = _roleTr(j['role'] as String? ?? '');
return '- $name ($role)';
}).join('\n');
return '## Ekip (${result.items.length} uye)\n$lines';
} catch (_) {
return '## Ekip\n(Veri alinamadi)';
}
}
static String _statusTr(String s) => switch (s) {
'pending' => 'Bekliyor',
'in_progress' => 'Devam ediyor',
'sent' => 'Gonderildi',
'revision' => 'Revizyon',
'delivered' => 'Teslim edildi',
'cancelled' => 'Iptal',
_ => s,
};
static String _roleTr(String s) => switch (s) {
'owner' => 'Sahibi',
'admin' => 'Yonetici',
'technician' => 'Teknisyen',
'delivery' => 'Teslimat',
'finance' => 'Finans',
'doctor' => 'Hekim',
_ => 'Uye',
};
}
+71
View File
@@ -0,0 +1,71 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
class AiService {
static const _baseUrl = 'https://api.featherless.ai/v1';
static const _apiKey =
'rc_e10f49aaa4f7af03dcd9da115cfc12cc1988665e895955c11f77788ee5ad93c6';
static const _model = 'Qwen/Qwen2.5-7B-Instruct';
AiService._();
static final instance = AiService._();
Stream<String> streamChat({
required String systemPrompt,
required List<Map<String, String>> messages,
}) async* {
final client = http.Client();
try {
final request = http.Request(
'POST',
Uri.parse('$_baseUrl/chat/completions'),
);
request.headers.addAll({
'Authorization': 'Bearer $_apiKey',
'Content-Type': 'application/json',
});
request.body = jsonEncode({
'model': _model,
'messages': [
{'role': 'system', 'content': systemPrompt},
...messages,
],
'stream': true,
'max_tokens': 2048,
'temperature': 0.7,
});
final response = await client.send(request);
if (response.statusCode != 200) {
final body = await response.stream.bytesToString();
String msg = 'API hatası ${response.statusCode}';
try {
final j = jsonDecode(body) as Map<String, dynamic>;
msg = (j['error'] as Map?)?['message'] as String? ?? msg;
} catch (_) {}
throw Exception(msg);
}
final lines = response.stream
.transform(utf8.decoder)
.transform(const LineSplitter());
await for (final line in lines) {
if (!line.startsWith('data: ')) continue;
final payload = line.substring(6).trim();
if (payload == '[DONE]') break;
try {
final j = jsonDecode(payload) as Map<String, dynamic>;
final choices = j['choices'] as List?;
if (choices == null || choices.isEmpty) continue;
final delta = choices.first['delta'] as Map<String, dynamic>?;
final content = delta?['content'] as String?;
if (content != null && content.isNotEmpty) yield content;
} catch (_) {}
}
} finally {
client.close();
}
}
}
+117
View File
@@ -0,0 +1,117 @@
import 'package:pocketbase/pocketbase.dart';
import '../api/pocketbase_client.dart';
import '../../models/job.dart';
class JobHistoryEntry {
const JobHistoryEntry({
required this.id,
required this.action,
required this.createdAt,
this.step,
this.note,
});
final String id;
final JobHistoryAction action;
final JobStep? step;
final String? note;
final DateTime createdAt;
}
enum JobHistoryAction {
accepted,
handedToClinic,
approved,
revisionRequested,
delivered,
cancelled,
}
extension JobHistoryActionExt on JobHistoryAction {
String get value => switch (this) {
JobHistoryAction.accepted => 'accepted',
JobHistoryAction.handedToClinic => 'handed_to_clinic',
JobHistoryAction.approved => 'approved',
JobHistoryAction.revisionRequested => 'revision_requested',
JobHistoryAction.delivered => 'delivered',
JobHistoryAction.cancelled => 'cancelled',
};
}
class JobHistoryService {
JobHistoryService._();
static final instance = JobHistoryService._();
PocketBase get _pb => PocketBaseClient.instance.pb;
String get _currentUserId =>
(_pb.authStore.record?.id) ?? (_pb.authStore.model as dynamic)?.id as String? ?? '';
Future<List<JobHistoryEntry>> listForJob(String jobId) async {
try {
final result = await _pb.collection('job_status_history').getList(
filter: 'job_id = "$jobId"',
perPage: 200,
);
return (result.items.map((r) {
final j = r.toJson();
String? str(dynamic v) {
final s = v as String?;
return (s == null || s.isEmpty) ? null : s;
}
return JobHistoryEntry(
id: j['id'] as String,
action: _parseAction(j['action_type'] as String? ?? ''),
step: str(j['step']) != null ? _parseStep(j['step'] as String) : null,
note: str(j['note']),
createdAt: DateTime.parse(j['created'] as String),
);
}).toList()..sort((a, b) => a.createdAt.compareTo(b.createdAt)));
} catch (_) {
return [];
}
}
static JobHistoryAction _parseAction(String s) => switch (s) {
'accepted' => JobHistoryAction.accepted,
'handed_to_clinic' => JobHistoryAction.handedToClinic,
'approved' => JobHistoryAction.approved,
'revision_requested' => JobHistoryAction.revisionRequested,
'delivered' => JobHistoryAction.delivered,
_ => JobHistoryAction.cancelled,
};
static JobStep _parseStep(String s) => switch (s) {
'alt_yapi_prova' => JobStep.altYapiProva,
'ust_yapi_prova' => JobStep.ustYapiProva,
'mum_prova' => JobStep.mumProva,
'disler_prova' => JobStep.dislerProva,
'dayanak_prova' => JobStep.dayanakProva,
'kron_prova' => JobStep.kronProva,
'cila_bitim' => JobStep.cilaBitim,
_ => JobStep.olcu,
};
Future<void> append({
required String jobId,
required String clinicTenantId,
required String labTenantId,
required JobHistoryAction action,
JobStep? step,
String? note,
String? userId,
}) async {
try {
await _pb.collection('job_status_history').create(body: {
'job_id': jobId,
'clinic_tenant_id': clinicTenantId,
'lab_tenant_id': labTenantId,
'completed_by': userId ?? _currentUserId,
'action_type': action.value,
if (step != null) 'step': step.value,
if (note != null && note.isNotEmpty) 'note': note,
});
} catch (_) {
// history failures must never block the main mutation
}
}
}
@@ -0,0 +1,64 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:go_router/go_router.dart';
import 'package:onesignal_flutter/onesignal_flutter.dart';
// ─── Replace with your OneSignal App ID from onesignal.com ──────────────────
const _kOneSignalAppId = '524cb6d8-2640-4f85-bb24-c9c762233de7';
// ────────────────────────────────────────────────────────────────────────────
class NotificationService {
NotificationService._();
static GoRouter? _router;
static bool _initialized = false;
static void setRouter(GoRouter router) => _router = router;
static bool get _supported =>
!kIsWeb && (Platform.isIOS || Platform.isAndroid || Platform.isMacOS);
static Future<void> init() async {
if (!_supported || _initialized) return;
_initialized = true;
OneSignal.initialize(_kOneSignalAppId);
await OneSignal.Notifications.requestPermission(true);
// Show notification even when app is in foreground
OneSignal.Notifications.addForegroundWillDisplayListener((event) {
event.notification.display();
});
// Tap → navigate to job detail
OneSignal.Notifications.addClickListener((event) {
final data = event.notification.additionalData;
if (data == null) return;
final jobId = data['job_id'] as String?;
final tenantType = data['tenant_type'] as String?;
if (jobId == null || _router == null) return;
if (tenantType == 'lab') {
_router!.push('/lab/jobs/$jobId');
} else {
_router!.push('/clinic/jobs/$jobId');
}
});
}
/// Call after successful login. Links the OneSignal player to this user.
static Future<void> loginUser(String userId, {bool isLab = false}) async {
if (!_supported) return;
try {
await OneSignal.login(userId);
OneSignal.User.addTagWithKey('tenant_type', isLab ? 'lab' : 'clinic');
} catch (_) {}
}
/// Call on logout.
static Future<void> logoutUser() async {
if (!_supported) return;
try {
await OneSignal.logout();
} catch (_) {}
}
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:pocketbase/pocketbase.dart';
import '../api/pocketbase_client.dart';
typedef UnsubFn = Future<void> Function();
class RealtimeService {
RealtimeService._();
static final instance = RealtimeService._();
final _pb = PocketBaseClient.instance.pb;
UnsubFn watch(
String collection, {
String topic = '*',
String filter = '',
required void Function(RecordSubscriptionEvent) onEvent,
}) {
UnsubFn? cancel;
_pb.collection(collection).subscribe(topic, onEvent, filter: filter).then((fn) {
cancel = fn;
});
return () async {
try {
final fn = cancel;
if (fn != null) {
await fn();
} else {
await _pb.collection(collection).unsubscribe(topic);
}
} catch (_) {
await _pb.collection(collection).unsubscribe(topic);
}
};
}
}
+299
View File
@@ -0,0 +1,299 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart';
abstract final class AppColors {
// Primary — professional navy
static const primary = Color(0xFF1E3A5F);
static const onPrimary = Color(0xFFFFFFFF);
// Accent — sky blue CTA
static const accent = Color(0xFF0369A1);
static const onAccent = Color(0xFFFFFFFF);
// Status
static const pending = Color(0xFFF59E0B);
static const pendingBg = Color(0xFFFFFBEB);
static const inProgress = Color(0xFF0369A1);
static const inProgressBg = Color(0xFFEFF6FF);
static const success = Color(0xFF059669);
static const successBg = Color(0xFFECFDF5);
static const cancelled = Color(0xFFDC2626);
static const cancelledBg = Color(0xFFFEF2F2);
// Surfaces
static const background = Color(0xFFF1F5F9);
static const surface = Color(0xFFFFFFFF);
static const surfaceVariant = Color(0xFFF8FAFC);
static const muted = Color(0xFFE2E8F0);
static const border = Color(0xFFE2E8F0);
// Text
static const textPrimary = Color(0xFF0F172A);
static const textSecondary = Color(0xFF64748B);
static const textMuted = Color(0xFF94A3B8);
// Dark variants
static const darkBackground = Color(0xFF0F172A);
static const darkSurface = Color(0xFF1E293B);
static const darkSurfaceVariant = Color(0xFF273344);
static const darkBorder = Color(0xFF334155);
static const darkTextPrimary = Color(0xFFF1F5F9);
static const darkTextSecondary = Color(0xFF94A3B8);
}
abstract final class AppLayout {
/// Window width above which the sidebar navigation is shown instead of bottom nav.
static const double sidebarBreakpoint = 720.0;
/// Window width above which wide-desktop content layouts activate
/// (e.g., 3-column stat card row, 2-column forms).
static const double wideBreakpoint = 1100.0;
/// Maximum content width used for dashboard horizontal padding.
static const double contentMaxWidth = 1040.0;
}
abstract final class AppTheme {
static TextTheme _buildTextTheme(Color bodyColor, Color displayColor) {
final base = GoogleFonts.plusJakartaSansTextTheme();
return base.copyWith(
displayLarge: base.displayLarge?.copyWith(color: displayColor, fontWeight: FontWeight.w800),
displayMedium: base.displayMedium?.copyWith(color: displayColor, fontWeight: FontWeight.w700),
headlineLarge: base.headlineLarge?.copyWith(color: displayColor, fontWeight: FontWeight.w700),
headlineMedium: base.headlineMedium?.copyWith(color: displayColor, fontWeight: FontWeight.w700),
headlineSmall: base.headlineSmall?.copyWith(color: displayColor, fontWeight: FontWeight.w600),
titleLarge: base.titleLarge?.copyWith(color: displayColor, fontWeight: FontWeight.w600),
titleMedium: base.titleMedium?.copyWith(color: displayColor, fontWeight: FontWeight.w600),
titleSmall: base.titleSmall?.copyWith(color: displayColor, fontWeight: FontWeight.w500),
bodyLarge: base.bodyLarge?.copyWith(color: bodyColor),
bodyMedium: base.bodyMedium?.copyWith(color: bodyColor),
bodySmall: base.bodySmall?.copyWith(color: AppColors.textSecondary),
labelLarge: base.labelLarge?.copyWith(fontWeight: FontWeight.w600),
labelMedium: base.labelMedium?.copyWith(fontWeight: FontWeight.w500),
);
}
static final light = ThemeData(
useMaterial3: true,
colorScheme: ColorScheme(
brightness: Brightness.light,
primary: AppColors.primary,
onPrimary: AppColors.onPrimary,
primaryContainer: const Color(0xFFDBEAFE),
onPrimaryContainer: AppColors.primary,
secondary: AppColors.accent,
onSecondary: AppColors.onAccent,
secondaryContainer: const Color(0xFFE0F2FE),
onSecondaryContainer: AppColors.accent,
tertiary: AppColors.success,
onTertiary: Colors.white,
tertiaryContainer: AppColors.successBg,
onTertiaryContainer: AppColors.success,
error: AppColors.cancelled,
onError: Colors.white,
errorContainer: AppColors.cancelledBg,
onErrorContainer: AppColors.cancelled,
surface: AppColors.surface,
onSurface: AppColors.textPrimary,
surfaceContainerHighest: AppColors.surfaceVariant,
onSurfaceVariant: AppColors.textSecondary,
outline: AppColors.border,
outlineVariant: AppColors.muted,
scrim: Colors.black54,
inverseSurface: AppColors.darkSurface,
onInverseSurface: AppColors.darkTextPrimary,
inversePrimary: const Color(0xFF93C5FD),
),
scaffoldBackgroundColor: AppColors.background,
textTheme: _buildTextTheme(AppColors.textPrimary, AppColors.textPrimary),
appBarTheme: AppBarTheme(
backgroundColor: AppColors.surface,
foregroundColor: AppColors.textPrimary,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
shadowColor: Colors.transparent,
centerTitle: false,
systemOverlayStyle: SystemUiOverlayStyle.dark,
titleTextStyle: GoogleFonts.plusJakartaSans(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
iconTheme: const IconThemeData(color: AppColors.textPrimary, size: 22),
),
cardTheme: CardThemeData(
elevation: 0,
color: AppColors.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: AppColors.border, width: 1),
),
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: AppColors.surface,
elevation: 0,
shadowColor: Colors.transparent,
indicatorColor: const Color(0xFFDBEAFE),
iconTheme: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return const IconThemeData(color: AppColors.primary, size: 22);
}
return IconThemeData(color: AppColors.textSecondary, size: 22);
}),
labelTextStyle: WidgetStateProperty.resolveWith((states) {
final style = GoogleFonts.plusJakartaSans(fontSize: 11);
if (states.contains(WidgetState.selected)) {
return style.copyWith(fontWeight: FontWeight.w600, color: AppColors.primary);
}
return style.copyWith(fontWeight: FontWeight.w500, color: AppColors.textSecondary);
}),
surfaceTintColor: Colors.transparent,
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: AppColors.onPrimary,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
textStyle: GoogleFonts.plusJakartaSans(fontSize: 15, fontWeight: FontWeight.w600),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primary,
minimumSize: const Size(0, 48),
side: const BorderSide(color: AppColors.border, width: 1.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
textStyle: GoogleFonts.plusJakartaSans(fontSize: 15, fontWeight: FontWeight.w600),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.surfaceVariant,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.accent, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.cancelled, width: 1.5),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
labelStyle: GoogleFonts.plusJakartaSans(color: AppColors.textSecondary),
hintStyle: GoogleFonts.plusJakartaSans(color: AppColors.textMuted),
),
chipTheme: ChipThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
side: BorderSide.none,
),
dividerTheme: const DividerThemeData(
color: AppColors.border,
thickness: 1,
space: 1,
),
listTileTheme: const ListTileThemeData(
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
),
);
static final dark = ThemeData(
useMaterial3: true,
colorScheme: ColorScheme(
brightness: Brightness.dark,
primary: const Color(0xFF93C5FD),
onPrimary: const Color(0xFF1E3A5F),
primaryContainer: const Color(0xFF1E3A5F),
onPrimaryContainer: const Color(0xFFDBEAFE),
secondary: const Color(0xFF7DD3FC),
onSecondary: const Color(0xFF0C4A6E),
secondaryContainer: const Color(0xFF0C4A6E),
onSecondaryContainer: const Color(0xFFE0F2FE),
tertiary: const Color(0xFF6EE7B7),
onTertiary: const Color(0xFF064E3B),
tertiaryContainer: const Color(0xFF064E3B),
onTertiaryContainer: const Color(0xFFD1FAE5),
error: const Color(0xFFFCA5A5),
onError: const Color(0xFF7F1D1D),
errorContainer: const Color(0xFF7F1D1D),
onErrorContainer: const Color(0xFFFEE2E2),
surface: AppColors.darkSurface,
onSurface: AppColors.darkTextPrimary,
surfaceContainerHighest: AppColors.darkSurfaceVariant,
onSurfaceVariant: AppColors.darkTextSecondary,
outline: AppColors.darkBorder,
outlineVariant: const Color(0xFF1E293B),
scrim: Colors.black87,
inverseSurface: const Color(0xFFF1F5F9),
onInverseSurface: AppColors.textPrimary,
inversePrimary: AppColors.primary,
),
scaffoldBackgroundColor: AppColors.darkBackground,
textTheme: _buildTextTheme(AppColors.darkTextPrimary, AppColors.darkTextPrimary),
appBarTheme: AppBarTheme(
backgroundColor: AppColors.darkSurface,
foregroundColor: AppColors.darkTextPrimary,
elevation: 0,
scrolledUnderElevation: 1,
systemOverlayStyle: SystemUiOverlayStyle.light,
titleTextStyle: GoogleFonts.plusJakartaSans(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.darkTextPrimary,
),
),
cardTheme: CardThemeData(
elevation: 0,
color: AppColors.darkSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: AppColors.darkBorder, width: 1),
),
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: AppColors.darkSurface,
elevation: 0,
indicatorColor: const Color(0xFF1E3A5F),
iconTheme: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return const IconThemeData(color: Color(0xFF93C5FD), size: 22);
}
return IconThemeData(color: AppColors.darkTextSecondary, size: 22);
}),
labelTextStyle: WidgetStateProperty.resolveWith((states) {
final style = GoogleFonts.plusJakartaSans(fontSize: 11);
if (states.contains(WidgetState.selected)) {
return style.copyWith(fontWeight: FontWeight.w600, color: const Color(0xFF93C5FD));
}
return style.copyWith(fontWeight: FontWeight.w500, color: AppColors.darkTextSecondary);
}),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF93C5FD),
foregroundColor: const Color(0xFF1E3A5F),
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
textStyle: GoogleFonts.plusJakartaSans(fontSize: 15, fontWeight: FontWeight.w600),
),
),
dividerTheme: const DividerThemeData(
color: AppColors.darkBorder,
thickness: 1,
space: 1,
),
);
}
+35
View File
@@ -0,0 +1,35 @@
class CurrencyFormatter {
static const _symbols = {
'TRY': '',
'USD': '\$',
'EUR': '',
'GBP': '£',
'AED': 'د.إ',
};
static const _rtlSymbols = {'AED'};
static String symbol(String code) => _symbols[code] ?? code;
static String format(double amount, String currencyCode) {
final sym = symbol(currencyCode);
final isRtl = _rtlSymbols.contains(currencyCode);
final value = _formatNumber(amount);
return isRtl ? '$value $sym' : '$sym$value';
}
static String _formatNumber(double amount) {
final formatted = amount.toStringAsFixed(2);
final parts = formatted.split('.');
final intPart = parts[0];
final decPart = parts[1];
final buf = StringBuffer();
final digits = intPart.replaceAll('-', '');
final isNeg = intPart.startsWith('-');
for (int i = 0; i < digits.length; i++) {
if (i > 0 && (digits.length - i) % 3 == 0) buf.write(',');
buf.write(digits[i]);
}
return '${isNeg ? '-' : ''}$buf.$decPart';
}
}
+40
View File
@@ -0,0 +1,40 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import '../api/pocketbase_client.dart';
import '../../models/job_file.dart';
import '../theme/app_theme.dart';
class FileDownloadHelper {
static Future<void> download(BuildContext context, JobFile file, {Rect? shareOrigin}) async {
if (file.downloadUrl.isEmpty) return;
final messenger = ScaffoldMessenger.of(context);
try {
final pb = PocketBaseClient.instance.pb;
final fileToken = await pb.files.getToken();
final uri = Uri.parse('${file.downloadUrl}?token=$fileToken');
final response = await http.get(uri);
if (response.statusCode != 200) throw Exception('HTTP ${response.statusCode}');
final dir = await getTemporaryDirectory();
final path = '${dir.path}/${file.name}';
await File(path).writeAsBytes(response.bodyBytes);
await Share.shareXFiles(
[XFile(path, mimeType: file.mimeType ?? 'application/octet-stream')],
subject: file.name,
sharePositionOrigin: shareOrigin ?? const Rect.fromLTWH(0, 0, 1, 1),
);
} catch (e) {
if (context.mounted) {
messenger.showSnackBar(
SnackBar(
content: Text('İndirilemedi: $e'),
backgroundColor: AppColors.cancelled,
),
);
}
}
}
}
+72
View File
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
class AppSearchField extends StatelessWidget {
const AppSearchField({
super.key,
required this.controller,
required this.onChanged,
this.hint,
});
final TextEditingController controller;
final ValueChanged<String> onChanged;
final String? hint;
@override
Widget build(BuildContext context) {
return Container(
color: AppColors.surface,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: ListenableBuilder(
listenable: controller,
builder: (context, _) => Container(
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
),
child: TextField(
controller: controller,
onChanged: onChanged,
style: const TextStyle(
fontSize: 14,
color: AppColors.textPrimary,
),
decoration: InputDecoration(
hintText: hint ?? 'Ara...',
hintStyle: const TextStyle(
color: AppColors.textMuted,
fontSize: 14,
),
prefixIcon: const Icon(
Icons.search_rounded,
color: AppColors.textMuted,
size: 20,
),
suffixIcon: controller.text.isNotEmpty
? GestureDetector(
onTap: () {
controller.clear();
onChanged('');
},
child: const Padding(
padding: EdgeInsets.all(12),
child: Icon(
Icons.close_rounded,
color: AppColors.textMuted,
size: 16,
),
),
)
: null,
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
),
);
}
}
+328
View File
@@ -0,0 +1,328 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../theme/app_theme.dart';
import 'tooth_logo.dart';
class GradientAppBar extends StatelessWidget implements PreferredSizeWidget {
const GradientAppBar({
super.key,
required this.title,
required this.category,
this.actions = const [],
this.searchController,
this.onSearchChanged,
this.searchHint,
});
final String title;
final String category;
final List<Widget> actions;
final TextEditingController? searchController;
final ValueChanged<String>? onSearchChanged;
final String? searchHint;
bool get _hasSearch =>
searchController != null && onSearchChanged != null;
@override
Size get preferredSize =>
Size.fromHeight(kToolbarHeight + (_hasSearch ? 52.0 : 0.0));
@override
Widget build(BuildContext context) {
final isDesktop =
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
final searchBottom = _hasSearch
? _SearchBarBottom(
controller: searchController!,
onChanged: onSearchChanged!,
hint: searchHint ?? 'Ara...',
)
: null;
if (isDesktop) {
return AppBar(
backgroundColor: AppColors.surface,
foregroundColor: AppColors.textPrimary,
elevation: 0,
scrolledUnderElevation: 0,
automaticallyImplyLeading: false,
titleSpacing: 24,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'DLS',
style: TextStyle(
fontSize: 11,
color: AppColors.textSecondary.withValues(alpha: 0.8),
letterSpacing: 0.3,
),
),
Text(
title,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
],
),
actions: [
...actions,
if (actions.isNotEmpty) const SizedBox(width: 8),
],
iconTheme:
const IconThemeData(color: AppColors.textSecondary, size: 22),
actionsIconTheme:
const IconThemeData(color: AppColors.textSecondary, size: 22),
bottom: searchBottom,
);
}
return AppBar(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
elevation: 0,
systemOverlayStyle: SystemUiOverlayStyle.light,
automaticallyImplyLeading: false,
leadingWidth: 60,
leading: Padding(
padding: const EdgeInsets.only(left: 16, top: 8, bottom: 8),
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child:
const Center(child: ToothLogo(size: 20, color: Colors.white)),
),
),
titleSpacing: 8,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
category,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.65),
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 1.5,
),
),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
actions: actions.isNotEmpty
? [...actions, const SizedBox(width: 4)]
: null,
iconTheme: const IconThemeData(color: Colors.white, size: 22),
actionsIconTheme:
const IconThemeData(color: Colors.white, size: 22),
flexibleSpace: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF0F172A), AppColors.primary],
),
),
),
bottom: searchBottom,
);
}
}
// ── iOS-26-style search bar shown below the AppBar title ─────────────────────
class _SearchBarBottom extends StatelessWidget implements PreferredSizeWidget {
const _SearchBarBottom({
required this.controller,
required this.onChanged,
required this.hint,
});
final TextEditingController controller;
final ValueChanged<String> onChanged;
final String hint;
@override
Size get preferredSize => const Size.fromHeight(52);
@override
Widget build(BuildContext context) {
final isDesktop =
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
final bg = isDesktop
? AppColors.surfaceVariant
: Colors.white.withValues(alpha: 0.15);
final textColor = isDesktop ? AppColors.textPrimary : Colors.white;
final iconColor = isDesktop
? AppColors.textMuted
: Colors.white.withValues(alpha: 0.65);
final hintColor = isDesktop
? AppColors.textMuted
: Colors.white.withValues(alpha: 0.5);
return SizedBox(
height: 52,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 10),
child: ListenableBuilder(
listenable: controller,
builder: (context, _) => Container(
height: 38,
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(12),
border: isDesktop
? Border.all(color: AppColors.border)
: null,
),
child: TextField(
controller: controller,
onChanged: onChanged,
style: TextStyle(color: textColor, fontSize: 15),
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(color: hintColor, fontSize: 15),
prefixIcon: Padding(
padding: const EdgeInsets.only(left: 10, right: 6),
child: Icon(Icons.search_rounded,
size: 18, color: iconColor),
),
prefixIconConstraints:
const BoxConstraints(minWidth: 36, minHeight: 36),
suffixIcon: controller.text.isNotEmpty
? GestureDetector(
onTap: () {
controller.clear();
onChanged('');
},
child: Padding(
padding: const EdgeInsets.only(right: 10),
child: Icon(Icons.close_rounded,
size: 16, color: iconColor),
),
)
: null,
suffixIconConstraints:
const BoxConstraints(minWidth: 32, minHeight: 36),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 10),
isDense: true,
),
),
),
),
),
);
}
}
// ── Sort / filter bottom sheet ────────────────────────────────────────────────
Future<int?> showSortSheet(
BuildContext context, {
required String title,
required List<String> options,
required int current,
}) {
return showModalBottomSheet<int>(
context: context,
backgroundColor: Colors.transparent,
builder: (ctx) => _SortSheet(
title: title,
options: options,
current: current,
),
);
}
class _SortSheet extends StatelessWidget {
const _SortSheet({
required this.title,
required this.options,
required this.current,
});
final String title;
final List<String> options;
final int current;
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppColors.border,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
title,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
),
),
const SizedBox(height: 8),
for (int i = 0; i < options.length; i++)
ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 20),
title: Text(
options[i],
style: TextStyle(
color: i == current
? AppColors.primary
: AppColors.textPrimary,
fontWeight: i == current
? FontWeight.w600
: FontWeight.normal,
),
),
trailing: i == current
? const Icon(Icons.check_rounded,
color: AppColors.primary, size: 20)
: null,
onTap: () => Navigator.pop(context, i),
),
SizedBox(height: MediaQuery.paddingOf(context).bottom + 8),
],
),
);
}
}
+121
View File
@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
class PillTabs extends StatelessWidget {
const PillTabs({
super.key,
required this.tabs,
required this.selected,
required this.onSelect,
this.counts,
});
final List<String> tabs;
final int selected;
final ValueChanged<int> onSelect;
final List<int?>? counts;
@override
Widget build(BuildContext context) {
return Container(
color: AppColors.surface,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
child: Row(
children: [
for (int i = 0; i < tabs.length; i++) ...[
if (i > 0) const SizedBox(width: 8),
_PillTab(
label: tabs[i],
count: counts != null && i < counts!.length ? counts![i] : null,
selected: selected == i,
onTap: () => onSelect(i),
),
],
],
),
),
const Divider(height: 1, thickness: 1, color: AppColors.border),
],
),
);
}
}
class _PillTab extends StatelessWidget {
const _PillTab({
required this.label,
required this.selected,
required this.onTap,
this.count,
});
final String label;
final bool selected;
final VoidCallback onTap;
final int? count;
@override
Widget build(BuildContext context) {
return Semantics(
label: label,
button: true,
excludeSemantics: true,
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: selected ? AppColors.primary : Colors.transparent,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: selected ? AppColors.primary : AppColors.border,
width: 1.5,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
color: selected ? Colors.white : AppColors.textSecondary,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
if (count != null) ...[
const SizedBox(width: 6),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: selected
? Colors.white.withValues(alpha: 0.25)
: AppColors.inProgressBg,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'$count',
style: TextStyle(
color: selected ? Colors.white : AppColors.inProgress,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
],
],
),
),
),
);
}
}
+104
View File
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
/// Renders the DLS brand logo — navy tooth + cyan chevrons.
///
/// [color] null → brand colors (#00397C tooth + #57B8CE chevrons).
/// Pass a color (e.g. Colors.white) for monochrome override on dark backgrounds.
class ToothLogo extends StatelessWidget {
const ToothLogo({
super.key,
required this.size,
this.color,
});
final double size;
final Color? color;
@override
Widget build(BuildContext context) {
return SizedBox(
width: size * 1.9,
height: size,
child: CustomPaint(
painter: _DlsLogoPainter(color: color),
),
);
}
}
class _DlsLogoPainter extends CustomPainter {
const _DlsLogoPainter({this.color});
final Color? color;
static const _navy = Color(0xFF00397C);
static const _cyan = Color(0xFF57B8CE);
@override
void paint(Canvas canvas, Size size) {
final toothColor = color ?? _navy;
final chevronColor = color ?? _cyan;
// Content bounding box in SVG 200×200 space: x=[42.5..157.5], y=[72..133]
// Width=115, Height=61 → aspect ~1.885 ≈ widget aspect 1.9
const svgLeft = 42.5, svgTop = 72.0, svgWidth = 115.0, svgHeight = 61.0;
final s = size.height / svgHeight;
final dx = (size.width - svgWidth * s) / 2.0 - svgLeft * s;
final dy = (size.height - svgHeight * s) / 2.0 - svgTop * s;
canvas.translate(dx, dy);
canvas.scale(s);
_drawTooth(canvas, toothColor);
_drawChevrons(canvas, chevronColor);
}
static void _drawTooth(Canvas canvas, Color color) {
// SVG path with scale(0.58) + translate(100,100) applied inline.
const cx = 100.0, cy = 100.0, sc = 0.58;
double px(double v) => cx + v * sc;
double py(double v) => cy + v * sc;
final path = Path()
..moveTo(px(0), py(-46))
..cubicTo(px(-22), py(-50), px(-44), py(-38), px(-44), py(-12))
..cubicTo(px(-44), py(8), px(-34), py(32), px(-26), py(46))
..cubicTo(px(-20), py(57), px(-11), py(53), px(-8), py(33))
..cubicTo(px(-6), py(19), px(-2), py(17), px(0), py(17))
..cubicTo(px(2), py(17), px(6), py(19), px(8), py(33))
..cubicTo(px(11), py(53), px(20), py(57), px(26), py(46))
..cubicTo(px(34), py(32), px(44), py(8), px(44), py(-12))
..cubicTo(px(44), py(-38), px(22), py(-50), px(0), py(-46))
..close();
canvas.drawPath(path, Paint()..color = color..style = PaintingStyle.fill);
}
static void _drawChevrons(Canvas canvas, Color color) {
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 11.0
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
// Polyline points + translate(100,100). Left: (-52,-22)→(-34,0)→(-52,22)
canvas.drawPath(
Path()
..moveTo(48, 78)
..lineTo(66, 100)
..lineTo(48, 122),
paint,
);
// Right: (52,-22)→(34,0)→(52,22)
canvas.drawPath(
Path()
..moveTo(152, 78)
..lineTo(134, 100)
..lineTo(152, 122),
paint,
);
}
@override
bool shouldRepaint(_DlsLogoPainter old) => old.color != color;
}
+104
View File
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import '../../core/theme/app_theme.dart';
/// Animated floating blob background used on auth screens.
/// [bright] = true → white blobs (for dark/gradient backgrounds).
/// [bright] = false → primary/accent blobs (for light backgrounds).
class AnimatedAuthBg extends StatefulWidget {
const AnimatedAuthBg({super.key, this.bright = false});
final bool bright;
@override
State<AnimatedAuthBg> createState() => _AnimatedAuthBgState();
}
class _AnimatedAuthBgState extends State<AnimatedAuthBg>
with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
late Animation<double> _anim;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(seconds: 8),
)..repeat(reverse: true);
_anim = CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
Color _blob(double alpha) => widget.bright
? Colors.white.withValues(alpha: alpha * 1.5)
: AppColors.primary.withValues(alpha: alpha);
Color _blobAccent(double alpha) => widget.bright
? Colors.white.withValues(alpha: alpha * 1.2)
: AppColors.accent.withValues(alpha: alpha);
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _anim,
builder: (_, __) {
final t = _anim.value;
return Stack(
children: [
Positioned(
top: -80 + t * 30,
left: -60 + t * 20,
child: AuthBlob(size: 300, color: _blob(0.08)),
),
Positioned(
top: 200 - t * 40,
right: -100 + t * 25,
child: AuthBlob(size: 250, color: _blobAccent(0.06)),
),
Positioned(
bottom: 100 + t * 30,
left: 50 - t * 15,
child: AuthBlob(size: 200, color: _blob(0.05)),
),
Positioned(
bottom: -50 + t * 20,
right: -50 + t * 10,
child: AuthBlob(size: 280, color: _blobAccent(0.07)),
),
Positioned(
top: 350 + t * 25,
left: 80 + t * 20,
child: AuthBlob(size: 160, color: _blob(0.04)),
),
Positioned(
top: -40 - t * 10,
left: 120 + t * 30,
child: AuthBlob(size: 180, color: _blobAccent(0.05)),
),
],
);
},
);
}
}
/// A simple solid circle used as a background blob.
class AuthBlob extends StatelessWidget {
const AuthBlob({super.key, required this.size, required this.color});
final double size;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
);
}
}
@@ -0,0 +1,32 @@
import 'package:pocketbase/pocketbase.dart';
import '../../core/api/pocketbase_client.dart';
import '../../core/auth/auth_repository.dart';
class OnboardingRepository {
OnboardingRepository._();
static final instance = OnboardingRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<AuthResult> createTenantAndJoin({
required String kind,
required String companyName,
}) async {
final userId = _pb.authStore.record!.id;
final tenant = await _pb.collection('tenants').create(body: {
'kind': kind,
'company_name': companyName,
'status': 'active',
'default_currency': 'TRY',
});
await _pb.collection('tenant_members').create(body: {
'tenant_id': tenant.id,
'user_id': userId,
'role': 'owner',
});
return AuthRepository.instance.refreshSession();
}
}
+461
View File
@@ -0,0 +1,461 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/providers/auth_provider.dart';
import 'onboarding_repository.dart';
class OnboardingScreen extends ConsumerStatefulWidget {
const OnboardingScreen({super.key});
@override
ConsumerState<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends ConsumerState<OnboardingScreen>
with SingleTickerProviderStateMixin {
final _formKey = GlobalKey<FormState>();
final _nameCtrl = TextEditingController();
String _selectedKind = 'clinic';
bool _loading = false;
String? _error;
late AnimationController _animCtrl;
late Animation<double> _fadeAnim;
late Animation<Offset> _slideAnim;
@override
void initState() {
super.initState();
_animCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
_fadeAnim = CurvedAnimation(parent: _animCtrl, curve: Curves.easeOut);
_slideAnim = Tween<Offset>(
begin: const Offset(0, 0.08),
end: Offset.zero,
).animate(CurvedAnimation(parent: _animCtrl, curve: Curves.easeOutCubic));
_animCtrl.forward();
}
@override
void dispose() {
_animCtrl.dispose();
_nameCtrl.dispose();
super.dispose();
}
Future<void> _create() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_loading = true;
_error = null;
});
try {
final result = await OnboardingRepository.instance.createTenantAndJoin(
kind: _selectedKind,
companyName: _nameCtrl.text.trim(),
);
if (!mounted) return;
ref.read(authProvider.notifier).setActiveTenant(result.tenants.first);
} catch (e) {
setState(() {
_error = 'Hesap oluşturulamadı. Lütfen tekrar deneyin.';
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final size = MediaQuery.sizeOf(context);
return Scaffold(
backgroundColor: const Color(0xFF4F46E5),
body: Stack(
children: [
// ── Gradient background ──────────────────────────────────────────
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomCenter,
colors: [Color(0xFF3730A3), Color(0xFF6366F1)],
),
),
),
// ── Decorative circles ───────────────────────────────────────────
Positioned(
top: -40,
right: -60,
child: Container(
width: 220,
height: 220,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.06),
),
),
),
Positioned(
top: 80,
left: -70,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.04),
),
),
),
// ── Content ──────────────────────────────────────────────────────
Column(
children: [
// Header
SafeArea(
bottom: false,
child: SizedBox(
height: size.height * 0.26,
child: Stack(
children: [
// Sign out
Positioned(
right: 8,
top: 4,
child: TextButton.icon(
onPressed: () =>
ref.read(authProvider.notifier).signOut(),
icon: const Icon(Icons.logout_rounded,
color: Colors.white70, size: 18),
label: const Text(
'Çıkış',
style: TextStyle(color: Colors.white70),
),
),
),
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 68,
height: 68,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Colors.white.withValues(alpha: 0.3),
width: 1.5,
),
),
child: const Icon(
Icons.domain_add_rounded,
size: 32,
color: Colors.white,
),
),
const SizedBox(height: 14),
const Text(
'Kurumunuzu Oluşturun',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
),
),
const SizedBox(height: 4),
Text(
'Klinik veya laboratuvar olarak kayıt olun',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.70),
fontSize: 13,
),
),
],
),
),
],
),
),
),
// Form card
Expanded(
child: FadeTransition(
opacity: _fadeAnim,
child: SlideTransition(
position: _slideAnim,
child: Container(
decoration: BoxDecoration(
color: cs.surface,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(32),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 24,
offset: const Offset(0, -4),
),
],
),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(28, 32, 28, 24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Kurum Türünü Seçin',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 14),
// Kind cards
Row(
children: [
Expanded(
child: _KindCard(
icon: Icons.local_hospital_outlined,
label: 'Klinik',
description: 'Diş kliniği',
value: 'clinic',
selected: _selectedKind == 'clinic',
onTap: () => setState(
() => _selectedKind = 'clinic'),
),
),
const SizedBox(width: 12),
Expanded(
child: _KindCard(
icon: Icons.science_outlined,
label: 'Laboratuvar',
description: 'Diş laboratuvarı',
value: 'lab',
selected: _selectedKind == 'lab',
onTap: () =>
setState(() => _selectedKind = 'lab'),
),
),
],
),
const SizedBox(height: 24),
// Company name
Text(
'Kurum Adı',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 10),
TextFormField(
controller: _nameCtrl,
textInputAction: TextInputAction.done,
textCapitalization: TextCapitalization.words,
onFieldSubmitted: (_) => _create(),
decoration: InputDecoration(
labelText: _selectedKind == 'clinic'
? 'Klinik Adı'
: 'Laboratuvar Adı',
prefixIcon: const Icon(
Icons.business_outlined,
size: 20),
filled: true,
fillColor: cs.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(
color: Color(0xFF4F46E5), width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(
color: cs.error, width: 1.5),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide:
BorderSide(color: cs.error, width: 2),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 16),
),
validator: (v) {
if (v == null || v.trim().isEmpty) {
return 'Kurum adı gereklidir';
}
if (v.trim().length < 3) {
return 'En az 3 karakter olmalıdır';
}
return null;
},
),
// Error banner
if (_error != null) ...[
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: cs.errorContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.error_outline_rounded,
color: cs.onErrorContainer, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
_error!,
style: TextStyle(
color: cs.onErrorContainer,
fontSize: 13),
),
),
],
),
),
],
const SizedBox(height: 28),
FilledButton(
onPressed: _loading ? null : _create,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
backgroundColor: const Color(0xFF4F46E5),
),
child: _loading
? const SizedBox(
height: 22,
width: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
)
: const Text(
'Devam Et',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
),
),
),
),
],
),
],
),
);
}
}
class _KindCard extends StatelessWidget {
const _KindCard({
required this.icon,
required this.label,
required this.description,
required this.value,
required this.selected,
required this.onTap,
});
final IconData icon;
final String label;
final String description;
final String value;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: selected ? const Color(0xFF4F46E5) : cs.outlineVariant,
width: selected ? 2 : 1,
),
color: selected
? const Color(0xFF4F46E5).withValues(alpha: 0.08)
: cs.surfaceContainerLow,
),
child: Column(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: selected
? const Color(0xFF4F46E5).withValues(alpha: 0.12)
: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
size: 26,
color: selected
? const Color(0xFF4F46E5)
: cs.onSurfaceVariant,
),
),
const SizedBox(height: 10),
Text(
label,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: selected ? const Color(0xFF4F46E5) : cs.onSurface,
),
),
const SizedBox(height: 2),
Text(
description,
style: TextStyle(
fontSize: 11,
color: cs.onSurfaceVariant,
),
),
],
),
),
);
}
}
+888
View File
@@ -0,0 +1,888 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/l10n/app_strings.dart';
import '../../core/providers/auth_provider.dart';
import '../../core/providers/locale_provider.dart';
import '../../core/router/app_router.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/tooth_logo.dart';
class SignInScreen extends ConsumerStatefulWidget {
const SignInScreen({super.key});
@override
ConsumerState<SignInScreen> createState() => _SignInScreenState();
}
class _SignInScreenState extends ConsumerState<SignInScreen> {
final _formKey = GlobalKey<FormState>();
final _emailCtrl = TextEditingController();
final _passCtrl = TextEditingController();
bool _obscure = true;
@override
void dispose() {
_emailCtrl.dispose();
_passCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
await ref
.read(authProvider.notifier)
.signIn(_emailCtrl.text.trim(), _passCtrl.text);
}
@override
Widget build(BuildContext context) {
final auth = ref.watch(authProvider);
final s = ref.watch(stringsProvider);
final locale = ref.watch(localeProvider);
final isDesktop = MediaQuery.sizeOf(context).width > 800;
return Scaffold(
backgroundColor: AppColors.background,
body: isDesktop
? _buildDesktop(context, auth, s, locale)
: _buildMobile(context, auth, s, locale),
);
}
// ── Mobile ─────────────────────────────────────────────────────────────────
Widget _buildMobile(
BuildContext context, dynamic auth, AppStrings s, Locale locale) {
return Stack(
children: [
SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 56),
// Logo mark
Center(
child: Container(
width: 68,
height: 68,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF0B1D35), Color(0xFF1A5C8A)],
),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: const Color(0xFF0B1D35).withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child:
const Center(child: ToothLogo(size: 34, color: Colors.white)),
),
).animate().fadeIn(duration: 400.ms).scale(begin: const Offset(0.8, 0.8)),
const SizedBox(height: 24),
Center(
child: Text(
s.signInWelcome,
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
letterSpacing: -0.5,
),
),
).animate(delay: 60.ms).fadeIn(duration: 400.ms).slideY(begin: 0.1),
const SizedBox(height: 6),
Center(
child: Text(
s.signInSubtitle,
style: const TextStyle(
fontSize: 14, color: AppColors.textSecondary),
),
).animate(delay: 100.ms).fadeIn(duration: 400.ms),
const SizedBox(height: 36),
_buildFormFields(auth, s),
const SizedBox(height: 24),
_buildSignUpLink(context, s),
const SizedBox(height: 32),
],
),
),
),
Positioned(
top: MediaQuery.paddingOf(context).top + 12,
right: 12,
child: _LanguageButton(locale: locale, s: s, ref: ref),
),
],
);
}
// ── Desktop ────────────────────────────────────────────────────────────────
Widget _buildDesktop(
BuildContext context, dynamic auth, AppStrings s, Locale locale) {
return Row(
children: [
// LEFT PANEL
Expanded(
flex: 55,
child: Stack(
fit: StackFit.expand,
children: [
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
stops: [0.0, 0.55, 1.0],
colors: [
Color(0xFF080F1E),
Color(0xFF0D2D58),
Color(0xFF0E4A82),
],
),
),
),
const Positioned(top: -140, left: -140, child: _Ring(size: 520, opacity: 0.06)),
const Positioned(bottom: -100, right: -100, child: _Ring(size: 400, opacity: 0.05)),
const Positioned(top: 160, right: 60, child: _Ring(size: 100, opacity: 0.09)),
const Positioned(bottom: 220, left: 60, child: _Ring(size: 70, opacity: 0.07)),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 64, vertical: 52),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Colors.white.withValues(alpha: 0.2),
),
),
child: const Center(
child: ToothLogo(size: 20, color: Colors.white)),
),
const SizedBox(width: 12),
const Text(
'DLS',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w800,
letterSpacing: 1.5,
),
),
],
).animate().fadeIn(duration: 500.ms),
const Spacer(),
Text(
s.signInHeadline,
style: const TextStyle(
color: Colors.white,
fontSize: 46,
fontWeight: FontWeight.w800,
height: 1.1,
letterSpacing: -1.0,
),
)
.animate(delay: 100.ms)
.fadeIn(duration: 500.ms)
.slideY(begin: 0.1, end: 0),
const SizedBox(height: 18),
Text(
s.signInTagline,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.6),
fontSize: 16,
height: 1.6,
),
).animate(delay: 160.ms).fadeIn(duration: 500.ms),
const SizedBox(height: 44),
const _DashboardPreviewCard()
.animate(delay: 220.ms)
.fadeIn(duration: 600.ms)
.slideY(begin: 0.12, end: 0),
const Spacer(),
Text(
s.footerCopyright,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.3),
fontSize: 12,
),
).animate(delay: 300.ms).fadeIn(duration: 500.ms),
],
),
),
],
),
),
// RIGHT PANEL
Stack(
children: [
Container(
width: 460,
color: Colors.white,
child: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
child: ConstrainedBox(
constraints:
BoxConstraints(minHeight: constraints.maxHeight),
child: IntrinsicHeight(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 52, vertical: 40),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF0B1D35),
Color(0xFF1A5C8A)
],
),
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: ToothLogo(
size: 24, color: Colors.white)),
).animate().fadeIn(duration: 400.ms),
const SizedBox(height: 32),
Text(
s.signInWelcome,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
letterSpacing: -0.5,
),
)
.animate(delay: 60.ms)
.fadeIn(duration: 400.ms)
.slideY(begin: 0.08, end: 0),
const SizedBox(height: 6),
Text(
s.signInSubtitle,
style: const TextStyle(
fontSize: 15,
color: AppColors.textSecondary,
),
).animate(delay: 100.ms).fadeIn(duration: 400.ms),
const SizedBox(height: 40),
_buildFormFields(auth, s)
.animate(delay: 140.ms)
.fadeIn(duration: 400.ms)
.slideY(begin: 0.08, end: 0),
const SizedBox(height: 28),
_buildSignUpLink(context, s)
.animate(delay: 200.ms)
.fadeIn(duration: 400.ms),
],
),
),
),
),
),
),
),
),
Positioned(
top: MediaQuery.paddingOf(context).top + 16,
right: 16,
child: _LanguageButton(locale: locale, s: s, ref: ref),
),
],
),
],
);
}
// ── Form fields (shared) ────────────────────────────────────────────────────
Widget _buildFormFields(dynamic auth, AppStrings s) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Field(
controller: _emailCtrl,
label: s.emailAddress,
icon: Icons.email_outlined,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
validator: (v) =>
(v == null || v.trim().isEmpty) ? s.emailRequired : null,
),
const SizedBox(height: 14),
_Field(
controller: _passCtrl,
label: s.password,
icon: Icons.lock_outline_rounded,
obscureText: _obscure,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
suffixIcon: IconButton(
icon: Icon(
_obscure
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
size: 20,
color: AppColors.textSecondary,
),
onPressed: () => setState(() => _obscure = !_obscure),
),
validator: (v) =>
(v == null || v.isEmpty) ? s.passwordRequired : null,
),
if (auth.error != null) ...[
const SizedBox(height: 14),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFFFEF2F2),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: AppColors.cancelled.withValues(alpha: 0.25)),
),
child: Row(
children: [
const Icon(Icons.error_outline_rounded,
color: AppColors.cancelled, size: 16),
const SizedBox(width: 8),
Expanded(
child: Text(
auth.error!,
style: const TextStyle(
color: AppColors.cancelled, fontSize: 13),
),
),
],
),
),
],
const SizedBox(height: 24),
DecoratedBox(
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0B1D35), Color(0xFF1A5C8A)],
),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: const Color(0xFF0B1D35).withValues(alpha: 0.35),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: FilledButton(
onPressed: auth.isLoading ? null : _submit,
style: FilledButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
disabledForegroundColor: Colors.white.withValues(alpha: 0.5),
disabledBackgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
child: auth.isLoading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5, color: Colors.white),
)
: Text(
s.signIn,
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w600),
),
),
),
],
),
);
}
// ── Sign-up link ───────────────────────────────────────────────────────────
Widget _buildSignUpLink(BuildContext context, AppStrings s) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
s.noAccount,
style:
const TextStyle(color: AppColors.textSecondary, fontSize: 14),
),
TextButton(
onPressed: () => context.go(routeSignUp),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF0D4C85),
padding: const EdgeInsets.symmetric(horizontal: 8),
),
child: Text(
s.signUp,
style:
const TextStyle(fontWeight: FontWeight.w700, fontSize: 14),
),
),
],
);
}
}
// ── Language button ───────────────────────────────────────────────────────────
class _LanguageButton extends StatelessWidget {
const _LanguageButton(
{required this.locale, required this.s, required this.ref});
final Locale locale;
final AppStrings s;
final WidgetRef ref;
static const _flags = {
'tr': '🇹🇷',
'en': '🇬🇧',
'ru': '🇷🇺',
'ar': '🇸🇦',
'de': '🇩🇪',
};
@override
Widget build(BuildContext context) {
final flag = _flags[locale.languageCode] ?? '🌐';
return GestureDetector(
onTap: () => _showPicker(context),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(flag, style: const TextStyle(fontSize: 15)),
const SizedBox(width: 4),
const Icon(Icons.expand_more_rounded,
size: 14, color: AppColors.textSecondary),
],
),
),
);
}
void _showPicker(BuildContext context) {
final options = [
('tr', '🇹🇷', s.languageTurkish),
('en', '🇬🇧', s.languageEnglish),
('ru', '🇷🇺', s.languageRussian),
('ar', '🇸🇦', s.languageArabic),
('de', '🇩🇪', s.languageGerman),
];
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (_) => Container(
decoration: const BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppColors.border,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
Text(
s.languageSelection,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 12),
for (final (code, flag, label) in options)
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
leading: Text(flag, style: const TextStyle(fontSize: 24)),
title: Text(
label,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
),
trailing: locale.languageCode == code
? const Icon(Icons.check_circle_rounded,
color: AppColors.accent)
: null,
onTap: () {
ref.read(localeProvider.notifier).setLocale(Locale(code));
Navigator.pop(context);
},
),
SizedBox(height: MediaQuery.paddingOf(context).bottom + 4),
],
),
),
);
}
}
// ── Decorative ring ───────────────────────────────────────────────────────────
class _Ring extends StatelessWidget {
const _Ring({required this.size, required this.opacity});
final double size;
final double opacity;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Colors.white.withValues(alpha: opacity),
width: 1.5,
),
),
);
}
}
// ── Dashboard preview card (glassmorphism) ────────────────────────────────────
class _DashboardPreviewCard extends StatelessWidget {
const _DashboardPreviewCard();
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
child: Container(
width: 340,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Colors.white.withValues(alpha: 0.12),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.bar_chart_rounded,
color: Colors.white,
size: 15,
),
),
const SizedBox(width: 10),
Text(
'Bugünkü Durum',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
const Spacer(),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'Canlı',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: 18),
const Row(
children: [
_StatChip(value: '24', label: 'Aktif', color: Color(0xFF60A5FA)),
SizedBox(width: 8),
_StatChip(
value: '8', label: 'Bekliyor', color: Color(0xFFFBBF24)),
SizedBox(width: 8),
_StatChip(
value: '142', label: 'Bu ay', color: Color(0xFF34D399)),
],
),
const SizedBox(height: 18),
const _PreviewBar(
label: 'Zirkon', value: 0.76, color: Color(0xFF60A5FA)),
const SizedBox(height: 10),
const _PreviewBar(
label: 'Metal alt.', value: 0.48, color: Color(0xFFFBBF24)),
const SizedBox(height: 10),
const _PreviewBar(
label: 'Porselen', value: 0.62, color: Color(0xFF34D399)),
],
),
),
),
);
}
}
class _StatChip extends StatelessWidget {
const _StatChip({
required this.value,
required this.label,
required this.color,
});
final String value;
final String label;
final Color color;
@override
Widget build(BuildContext context) {
return Expanded(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withValues(alpha: 0.2)),
),
child: Column(
children: [
Text(
value,
style: TextStyle(
color: color,
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
label,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.55),
fontSize: 11,
),
),
],
),
),
);
}
}
class _PreviewBar extends StatelessWidget {
const _PreviewBar({
required this.label,
required this.value,
required this.color,
});
final String label;
final double value;
final Color color;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.65),
fontSize: 12,
),
),
Text(
'${(value * 100).toInt()}%',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.65),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 5),
LayoutBuilder(
builder: (_, constraints) => Stack(
children: [
Container(
height: 5,
width: constraints.maxWidth,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10),
),
),
Container(
height: 5,
width: constraints.maxWidth * value,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(10),
),
),
],
),
),
],
);
}
}
// ── Form field ────────────────────────────────────────────────────────────────
class _Field extends StatelessWidget {
const _Field({
required this.controller,
required this.label,
required this.icon,
this.keyboardType,
this.textInputAction,
this.obscureText = false,
this.suffixIcon,
this.onFieldSubmitted,
this.validator,
});
final TextEditingController controller;
final String label;
final IconData icon;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final bool obscureText;
final Widget? suffixIcon;
final ValueChanged<String>? onFieldSubmitted;
final FormFieldValidator<String>? validator;
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
keyboardType: keyboardType,
textInputAction: textInputAction,
obscureText: obscureText,
onFieldSubmitted: onFieldSubmitted,
validator: validator,
style: const TextStyle(fontSize: 15, color: AppColors.textPrimary),
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon, size: 20, color: AppColors.textSecondary),
suffixIcon: suffixIcon,
filled: true,
fillColor: const Color(0xFFF8FAFC),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF0D4C85), width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide:
const BorderSide(color: AppColors.cancelled, width: 1.5),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.cancelled, width: 2),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
labelStyle: const TextStyle(
color: AppColors.textSecondary, fontSize: 14),
),
);
}
}
+619
View File
@@ -0,0 +1,619 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/providers/auth_provider.dart';
import '../../core/router/app_router.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/tooth_logo.dart';
import 'auth_widgets.dart';
class SignUpScreen extends ConsumerStatefulWidget {
const SignUpScreen({super.key});
@override
ConsumerState<SignUpScreen> createState() => _SignUpScreenState();
}
class _SignUpScreenState extends ConsumerState<SignUpScreen> {
final _formKey = GlobalKey<FormState>();
final _firstNameCtrl = TextEditingController();
final _lastNameCtrl = TextEditingController();
final _emailCtrl = TextEditingController();
final _passCtrl = TextEditingController();
final _confirmPassCtrl = TextEditingController();
bool _obscure = true;
bool _obscureConfirm = true;
bool _loading = false;
String? _error;
@override
void dispose() {
_firstNameCtrl.dispose();
_lastNameCtrl.dispose();
_emailCtrl.dispose();
_passCtrl.dispose();
_confirmPassCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_loading = true;
_error = null;
});
try {
await ref.read(authProvider.notifier).register(
email: _emailCtrl.text.trim(),
password: _passCtrl.text,
firstName: _firstNameCtrl.text.trim(),
lastName: _lastNameCtrl.text.trim(),
);
} catch (e) {
setState(() {
_error = _parseError(e.toString());
_loading = false;
});
}
}
String _parseError(String msg) {
if (msg.contains('already') || msg.contains('unique') || msg.contains('UNIQUE')) {
return 'Bu e-posta adresi zaten kayıtlı.';
}
if (msg.contains('403') || msg.contains('Forbidden')) {
return 'Kayıt şu anda kapalı. Lütfen yönetici ile iletişime geçin.';
}
return 'Kayıt olunamadı. Lütfen tekrar deneyin.';
}
@override
Widget build(BuildContext context) {
final isDesktop = MediaQuery.sizeOf(context).width > 800;
return Scaffold(
backgroundColor: AppColors.background,
body: isDesktop ? _buildDesktop(context) : _buildMobile(context),
);
}
// ── Mobile layout ──────────────────────────────────────────────────────────
Widget _buildMobile(BuildContext context) {
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: _buildForm(context, isMobile: true),
),
);
}
// ── Desktop layout ─────────────────────────────────────────────────────────
Widget _buildDesktop(BuildContext context) {
return Row(
children: [
// LEFT PANEL — solid gradient + white animated blobs on top
Expanded(
flex: 5,
child: Stack(
fit: StackFit.expand,
children: [
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.primary, Color(0xFF1A5C8A)],
),
),
),
const AnimatedAuthBg(bright: true),
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 56),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Colors.white.withValues(alpha: 0.3),
width: 1.5,
),
),
child: const Center(child: ToothLogo(size: 38, color: Colors.white)),
),
const SizedBox(height: 24),
const Text(
'DLS',
style: TextStyle(
fontSize: 48,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: 2,
),
),
const SizedBox(height: 4),
Text(
'Dental Lab Sistemi',
style: TextStyle(
fontSize: 17,
color: Colors.white.withValues(alpha: 0.7),
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 48),
const _FeatureBullet(
icon: Icons.dashboard_rounded,
text: 'İş takibi tek ekranda',
),
const SizedBox(height: 16),
const _FeatureBullet(
icon: Icons.link_rounded,
text: 'Klinik-lab bağlantısı',
),
const SizedBox(height: 16),
const _FeatureBullet(
icon: Icons.bolt_rounded,
text: 'Gerçek zamanlı durum',
),
],
),
),
),
],
),
),
// RIGHT PANEL — light gray so white card stands out
Container(
width: 480,
color: AppColors.background,
child: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: IntrinsicHeight(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [_buildForm(context, isMobile: false)],
),
),
),
),
),
),
),
),
],
);
}
// ── Shared form content ────────────────────────────────────────────────────
Widget _buildForm(BuildContext context, {required bool isMobile}) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (isMobile) const SizedBox(height: 48),
// ── Back button + branding (mobile only) ───────────────────────
if (isMobile) ...[
Row(
children: [
IconButton(
onPressed: () => context.go(routeSignIn),
icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 20),
style: IconButton.styleFrom(
foregroundColor: AppColors.textPrimary,
backgroundColor: AppColors.surface,
padding: const EdgeInsets.all(10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: AppColors.border),
),
),
),
],
).animate().fadeIn(duration: 300.ms),
const SizedBox(height: 28),
Center(
child: Column(
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.primary, AppColors.accent],
),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: AppColors.accent.withValues(alpha: 0.3),
blurRadius: 18,
offset: const Offset(0, 6),
),
],
),
child: const Icon(
Icons.person_add_alt_1_rounded,
size: 32,
color: Colors.white,
),
),
const SizedBox(height: 16),
const Text(
'Hesap Oluştur',
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 4),
const Text(
'DLS ağına katılın',
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
),
).animate().fadeIn(duration: 400.ms).slideY(begin: -0.08, end: 0),
const SizedBox(height: 32),
],
// Desktop back button (outside card)
if (!isMobile) ...[
Row(
children: [
IconButton(
onPressed: () => context.go(routeSignIn),
icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 18),
style: IconButton.styleFrom(
foregroundColor: AppColors.textPrimary,
backgroundColor: AppColors.surface,
padding: const EdgeInsets.all(8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: const BorderSide(color: AppColors.border),
),
),
),
],
).animate().fadeIn(duration: 300.ms),
const SizedBox(height: 20),
],
// ── Form card ──────────────────────────────────────────────────
Container(
padding: EdgeInsets.all(isMobile ? 24 : 32),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isMobile ? 0.05 : 0.09),
blurRadius: isMobile ? 16 : 28,
spreadRadius: 0,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Heading inside card on desktop
if (!isMobile) ...[
const Text(
'Hesap Oluştur',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
).animate().fadeIn(duration: 400.ms).slideY(begin: -0.08, end: 0),
const SizedBox(height: 4),
const Text(
'DLS ağına katılın',
style: TextStyle(fontSize: 14, color: AppColors.textSecondary),
).animate(delay: 40.ms).fadeIn(duration: 400.ms),
const SizedBox(height: 24),
],
// Ad / Soyad satırı
Row(
children: [
Expanded(
child: _Field(
controller: _firstNameCtrl,
label: 'Ad',
icon: Icons.badge_outlined,
textCapitalization: TextCapitalization.words,
textInputAction: TextInputAction.next,
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Gerekli' : null,
),
),
const SizedBox(width: 12),
Expanded(
child: _Field(
controller: _lastNameCtrl,
label: 'Soyad',
icon: Icons.badge_outlined,
textCapitalization: TextCapitalization.words,
textInputAction: TextInputAction.next,
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Gerekli' : null,
),
),
],
),
const SizedBox(height: 12),
_Field(
controller: _emailCtrl,
label: 'E-posta',
icon: Icons.email_outlined,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
validator: (v) {
if (v == null || v.trim().isEmpty) return 'E-posta gereklidir';
if (!v.contains('@')) return 'Geçerli bir e-posta girin';
return null;
},
),
const SizedBox(height: 12),
_Field(
controller: _passCtrl,
label: 'Şifre',
icon: Icons.lock_outline_rounded,
obscureText: _obscure,
textInputAction: TextInputAction.next,
suffixIcon: IconButton(
icon: Icon(
_obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined,
size: 20,
color: AppColors.textSecondary,
),
onPressed: () => setState(() => _obscure = !_obscure),
),
validator: (v) {
if (v == null || v.isEmpty) return 'Şifre gereklidir';
if (v.length < 8) return 'En az 8 karakter olmalıdır';
return null;
},
),
const SizedBox(height: 12),
_Field(
controller: _confirmPassCtrl,
label: 'Şifre Tekrar',
icon: Icons.lock_outline_rounded,
obscureText: _obscureConfirm,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
suffixIcon: IconButton(
icon: Icon(
_obscureConfirm
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
size: 20,
color: AppColors.textSecondary,
),
onPressed: () => setState(() => _obscureConfirm = !_obscureConfirm),
),
validator: (v) =>
(v != _passCtrl.text) ? 'Şifreler eşleşmiyor' : null,
),
if (_error != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppColors.cancelledBg,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: AppColors.cancelled.withValues(alpha: 0.3)),
),
child: Row(
children: [
const Icon(Icons.error_outline_rounded,
color: AppColors.cancelled, size: 16),
const SizedBox(width: 8),
Expanded(
child: Text(
_error!,
style: const TextStyle(
color: AppColors.cancelled, fontSize: 13),
),
),
],
),
),
],
const SizedBox(height: 20),
FilledButton(
onPressed: _loading ? null : _submit,
style: FilledButton.styleFrom(
backgroundColor: AppColors.primary,
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
child: _loading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5, color: Colors.white),
)
: const Text(
'Kayıt Ol',
style:
TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
),
],
),
).animate(delay: 100.ms).fadeIn(duration: 400.ms).slideY(begin: 0.1, end: 0),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Zaten hesabın var mı?',
style: TextStyle(color: AppColors.textSecondary, fontSize: 14),
),
TextButton(
onPressed: () => context.go(routeSignIn),
style: TextButton.styleFrom(
foregroundColor: AppColors.accent,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
child: const Text(
'Giriş Yap',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
),
),
],
).animate(delay: 200.ms).fadeIn(duration: 400.ms),
SizedBox(height: isMobile ? 32 : 16),
],
),
);
}
}
// ── Feature bullet (desktop left panel) ──────────────────────────────────────
class _FeatureBullet extends StatelessWidget {
const _FeatureBullet({required this.icon, required this.text});
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
return Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 18, color: Colors.white),
),
const SizedBox(width: 14),
Text(
text,
style: TextStyle(
fontSize: 15,
color: Colors.white.withValues(alpha: 0.9),
fontWeight: FontWeight.w500,
),
),
],
);
}
}
// ── Form field ────────────────────────────────────────────────────────────────
class _Field extends StatelessWidget {
const _Field({
required this.controller,
required this.label,
required this.icon,
this.keyboardType,
this.textCapitalization = TextCapitalization.none,
this.textInputAction,
this.obscureText = false,
this.suffixIcon,
this.onFieldSubmitted,
this.validator,
});
final TextEditingController controller;
final String label;
final IconData icon;
final TextInputType? keyboardType;
final TextCapitalization textCapitalization;
final TextInputAction? textInputAction;
final bool obscureText;
final Widget? suffixIcon;
final ValueChanged<String>? onFieldSubmitted;
final FormFieldValidator<String>? validator;
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
keyboardType: keyboardType,
textCapitalization: textCapitalization,
textInputAction: textInputAction,
obscureText: obscureText,
onFieldSubmitted: onFieldSubmitted,
validator: validator,
style: const TextStyle(fontSize: 15, color: AppColors.textPrimary),
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon, size: 20, color: AppColors.textSecondary),
suffixIcon: suffixIcon,
filled: true,
fillColor: AppColors.background,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.accent, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.cancelled, width: 1.5),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.cancelled, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
labelStyle: const TextStyle(color: AppColors.textSecondary, fontSize: 14),
),
);
}
}
@@ -0,0 +1,40 @@
import 'package:pocketbase/pocketbase.dart';
import '../../../core/api/pocketbase_client.dart';
import '../../../models/connection.dart';
class ClinicConnectionsRepository {
ClinicConnectionsRepository._();
static final instance = ClinicConnectionsRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<List<Connection>> listConnections(String clinicTenantId) async {
final result = await _pb.collection('connections').getList(
filter: 'clinic_tenant_id = "$clinicTenantId"',
expand: 'lab_tenant_id,clinic_tenant_id',
perPage: 100,
);
return (result.items.map((r) => Connection.fromJson(r.toJson())).toList()
..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? '')));
}
Future<Connection> requestConnection({
required String clinicTenantId,
required String labTenantId,
}) async {
final record = await _pb.collection('connections').create(body: {
'clinic_tenant_id': clinicTenantId,
'lab_tenant_id': labTenantId,
'status': 'pending',
});
return Connection.fromJson(record.toJson());
}
Future<List<Map<String, dynamic>>> searchLabs(String query) async {
final result = await _pb.collection('tenants').getList(
filter: 'kind = "lab" && company_name ~ "$query"',
perPage: 20,
);
return result.items.map((r) => r.toJson()).toList();
}
}
@@ -0,0 +1,441 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/connection.dart';
import 'clinic_connections_repository.dart';
class ClinicConnectionsScreen extends ConsumerStatefulWidget {
const ClinicConnectionsScreen({super.key});
@override
ConsumerState<ClinicConnectionsScreen> createState() =>
_ClinicConnectionsScreenState();
}
class _ClinicConnectionsScreenState
extends ConsumerState<ClinicConnectionsScreen> {
late Future<List<Connection>> _future;
@override
void initState() {
super.initState();
_load();
}
void _load() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() {
_future = ClinicConnectionsRepository.instance
.listConnections(tenantId);
});
}
void _showSearchDialog() {
showDialog(
context: context,
builder: (ctx) => _LabSearchDialog(
onRequested: (labId, labName) async {
Navigator.of(ctx).pop();
final tenantId =
ref.read(authProvider).activeTenant!.tenant.id;
try {
await ClinicConnectionsRepository.instance.requestConnection(
clinicTenantId: tenantId,
labTenantId: labId,
);
_load();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'$labName\'a bağlantı talebi gönderildi.')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
},
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Bağlantılar'),
actions: [
IconButton(
icon: const Icon(Icons.add_link),
tooltip: 'Laboratuvar Bul',
onPressed: _showSearchDialog,
),
],
),
body: RefreshIndicator(
color: AppColors.accent,
onRefresh: () async => _load(),
child: FutureBuilder<List<Connection>>(
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,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Tekrar Dene'),
),
],
),
);
}
final connections = snap.data!;
if (connections.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.link_off,
color: AppColors.inProgress, size: 32),
),
const SizedBox(height: 16),
const Text(
'Henüz bağlantı yok',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: _showSearchDialog,
icon: const Icon(Icons.search),
label: const Text('Laboratuvar Bul'),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
itemCount: connections.length,
itemBuilder: (context, index) {
final conn = connections[index];
final statusColor = _statusColor(conn.status);
final statusBg = _statusBg(conn.status);
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.surface,
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: 44,
height: 44,
decoration: BoxDecoration(
color: statusBg,
borderRadius: BorderRadius.circular(12)),
child: Icon(Icons.science_outlined,
color: statusColor, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
conn.labName ?? 'Laboratuvar',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
if (conn.dateCreated != null) ...[
const SizedBox(height: 2),
Text(
_formatDate(conn.dateCreated!),
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary),
),
],
],
),
),
_StatusChip(status: conn.status),
],
),
),
);
},
);
},
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _showSearchDialog,
backgroundColor: AppColors.accent,
foregroundColor: Colors.white,
icon: const Icon(Icons.search),
label: const Text('Laboratuvar Bul'),
),
);
}
Color _statusColor(ConnectionStatus s) {
switch (s) {
case ConnectionStatus.pending:
return AppColors.pending;
case ConnectionStatus.approved:
return AppColors.success;
case ConnectionStatus.rejected:
return AppColors.cancelled;
}
}
Color _statusBg(ConnectionStatus s) {
switch (s) {
case ConnectionStatus.pending:
return AppColors.pendingBg;
case ConnectionStatus.approved:
return AppColors.successBg;
case ConnectionStatus.rejected:
return AppColors.cancelledBg;
}
}
String _formatDate(String dateStr) {
try {
final d = DateTime.parse(dateStr);
return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
} catch (_) {
return dateStr;
}
}
}
class _StatusChip extends StatelessWidget {
const _StatusChip({required this.status});
final ConnectionStatus status;
@override
Widget build(BuildContext context) {
final color = _color(status);
final bg = _bg(status);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(8),
),
child: Text(
status.label,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
Color _color(ConnectionStatus s) {
switch (s) {
case ConnectionStatus.pending:
return AppColors.pending;
case ConnectionStatus.approved:
return AppColors.success;
case ConnectionStatus.rejected:
return AppColors.cancelled;
}
}
Color _bg(ConnectionStatus s) {
switch (s) {
case ConnectionStatus.pending:
return AppColors.pendingBg;
case ConnectionStatus.approved:
return AppColors.successBg;
case ConnectionStatus.rejected:
return AppColors.cancelledBg;
}
}
}
class _LabSearchDialog extends StatefulWidget {
const _LabSearchDialog({required this.onRequested});
final void Function(String labId, String labName) onRequested;
@override
State<_LabSearchDialog> createState() => _LabSearchDialogState();
}
class _LabSearchDialogState extends State<_LabSearchDialog> {
final _searchController = TextEditingController();
List<Map<String, dynamic>> _results = [];
bool _isLoading = false;
bool _searched = false;
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _search() async {
final query = _searchController.text.trim();
if (query.isEmpty) return;
setState(() {
_isLoading = true;
_searched = true;
});
try {
final results =
await ClinicConnectionsRepository.instance.searchLabs(query);
setState(() {
_results = results;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Laboratuvar Bul'),
content: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
decoration: const InputDecoration(
hintText: 'Lab adı ile arayın...',
contentPadding: EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
),
onSubmitted: (_) => _search(),
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _search,
child: const Text('Ara'),
),
],
),
const SizedBox(height: 12),
if (_isLoading)
const Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(color: AppColors.accent),
)
else if (_searched && _results.isEmpty)
const Padding(
padding: EdgeInsets.all(16),
child: Text('Sonuç bulunamadı',
style: TextStyle(color: AppColors.textSecondary)),
)
else if (_results.isNotEmpty)
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 240),
child: ListView.builder(
shrinkWrap: true,
itemCount: _results.length,
itemBuilder: (context, index) {
final lab = _results[index];
final name =
lab['company_name'] as String? ?? 'Lab';
return ListTile(
dense: true,
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.inProgressBg,
borderRadius: BorderRadius.circular(8)),
child: const Icon(Icons.science_outlined,
color: AppColors.inProgress, size: 18),
),
title: Text(name,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppColors.textPrimary)),
subtitle: lab['member_number'] != null
? Text('No: ${lab['member_number']}',
style: const TextStyle(
color: AppColors.textSecondary))
: null,
onTap: () =>
widget.onRequested(lab['id'] as String, name),
);
},
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('İptal'),
),
],
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
import 'package:pocketbase/pocketbase.dart';
import '../../../core/api/pocketbase_client.dart';
import '../../../models/finance_entry.dart';
class ClinicFinanceRepository {
ClinicFinanceRepository._();
static final instance = ClinicFinanceRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<List<FinanceEntry>> listEntries(
String tenantId, {
String? status,
int page = 1,
int limit = 30,
}) async {
final filterParts = ['tenant_id = "$tenantId"', 'type = "payable"'];
if (status != null) filterParts.add('status = "$status"');
final result = await _pb.collection('finance_entries').getList(
page: page,
perPage: limit,
filter: filterParts.join(' && '),
expand: 'job_id',
);
return (result.items.map((r) => FinanceEntry.fromJson(r.toJson())).toList()
..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? '')));
}
Future<Map<String, double>> summary(String tenantId) async {
final all = await listEntries(tenantId, limit: 200);
double pending = 0, paid = 0;
for (final e in all) {
if (e.status == FinanceStatus.pending) {
pending += e.amount;
} else {
paid += e.amount;
}
}
return {'pending': pending, 'paid': paid};
}
Future<void> markPaid(String entryId) async {
await _pb.collection('finance_entries').update(entryId, body: {
'status': 'paid',
'paid_at': DateTime.now().toIso8601String(),
});
}
}
@@ -0,0 +1,534 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../core/providers/locale_provider.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/currency_formatter.dart';
import '../../../core/widgets/gradient_app_bar.dart';
import '../../../core/widgets/pill_tabs.dart';
import '../../../models/finance_entry.dart';
import 'clinic_finance_repository.dart';
enum _FinanceSort { newestFirst, byAmountDesc, byAmountAsc }
class ClinicFinanceScreen extends ConsumerStatefulWidget {
const ClinicFinanceScreen({super.key});
@override
ConsumerState<ClinicFinanceScreen> createState() =>
_ClinicFinanceScreenState();
}
class _ClinicFinanceScreenState extends ConsumerState<ClinicFinanceScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
late Future<Map<String, double>> _summaryFuture;
_FinanceSort _sort = _FinanceSort.newestFirst;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_tabController.addListener(() {
if (mounted) setState(() {});
});
_loadSummary();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _loadSummary() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() {
_summaryFuture =
ClinicFinanceRepository.instance.summary(tenantId);
});
}
Future<void> _showSortOptions() async {
final s = ref.read(stringsProvider);
final result = await showSortSheet(
context,
title: s.sort,
options: [s.sortNewest, s.sortAmountDesc, s.sortAmountAsc],
current: _sort.index,
);
if (result != null) {
setState(() => _sort = _FinanceSort.values[result]);
}
}
@override
Widget build(BuildContext context) {
final isSortActive = _sort != _FinanceSort.newestFirst;
final s = ref.watch(stringsProvider);
final currencyCode =
ref.watch(authProvider).activeTenant?.tenant.defaultCurrency ?? 'TRY';
return Scaffold(
backgroundColor: AppColors.background,
appBar: GradientAppBar(
title: s.finance,
category: s.clinicCategory,
actions: [
IconButton(
onPressed: _showSortOptions,
tooltip: 'Sırala',
icon: Badge(
isLabelVisible: isSortActive,
smallSize: 8,
backgroundColor: AppColors.accent,
child: const Icon(Icons.sort_rounded),
),
),
],
),
body: Column(
children: [
FutureBuilder<Map<String, double>>(
future: _summaryFuture,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const LinearProgressIndicator(
color: AppColors.accent);
}
final summary = snap.data ?? {'pending': 0.0, 'paid': 0.0};
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: _SummaryCard(
label: s.pendingReceivable,
amount: summary['pending'] ?? 0.0,
currencyCode: currencyCode,
color: AppColors.pending,
bgColor: AppColors.pendingBg,
icon: Icons.hourglass_empty_rounded,
),
),
const SizedBox(width: 12),
Expanded(
child: _SummaryCard(
label: s.collected,
amount: summary['paid'] ?? 0.0,
currencyCode: currencyCode,
color: AppColors.success,
bgColor: AppColors.successBg,
icon: Icons.check_circle_outline,
),
),
],
),
);
},
),
PillTabs(
tabs: [s.pending, s.collected],
selected: _tabController.index,
onSelect: (i) => _tabController.animateTo(i),
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_FinanceTab(
status: 'pending',
sort: _sort,
onPaymentMade: _loadSummary,
currencyCode: currencyCode,
),
_FinanceTab(
status: 'paid',
sort: _sort,
onPaymentMade: _loadSummary,
currencyCode: currencyCode,
),
],
),
),
],
),
);
}
}
class _SummaryCard extends StatelessWidget {
const _SummaryCard({
required this.label,
required this.amount,
required this.currencyCode,
required this.color,
required this.bgColor,
required this.icon,
});
final String label;
final double amount;
final String currencyCode;
final Color color;
final Color bgColor;
final IconData icon;
@override
Widget build(BuildContext context) {
return 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.06),
blurRadius: 12,
offset: const Offset(0, 4))
],
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(12)),
child: Icon(icon, color: color, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
CurrencyFormatter.format(amount, currencyCode),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: color,
height: 1),
),
const SizedBox(height: 3),
Text(label,
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
fontWeight: FontWeight.w500)),
],
),
),
],
),
);
}
}
class _FinanceTab extends ConsumerStatefulWidget {
const _FinanceTab({
required this.status,
required this.sort,
required this.onPaymentMade,
required this.currencyCode,
});
final String status;
final _FinanceSort sort;
final VoidCallback onPaymentMade;
final String currencyCode;
@override
ConsumerState<_FinanceTab> createState() => _FinanceTabState();
}
class _FinanceTabState extends ConsumerState<_FinanceTab> {
late Future<List<FinanceEntry>> _future;
@override
void initState() {
super.initState();
_load();
}
void _load() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() {
_future = ClinicFinanceRepository.instance.listEntries(
tenantId,
status: widget.status,
limit: 100,
);
});
}
List<FinanceEntry> _sorted(List<FinanceEntry> entries) {
final list = List<FinanceEntry>.from(entries);
switch (widget.sort) {
case _FinanceSort.newestFirst:
list.sort((a, b) {
final da = a.dateCreated != null
? DateTime.tryParse(a.dateCreated!)
: null;
final db = b.dateCreated != null
? DateTime.tryParse(b.dateCreated!)
: null;
if (da == null && db == null) return 0;
if (da == null) return 1;
if (db == null) return -1;
return db.compareTo(da);
});
case _FinanceSort.byAmountDesc:
list.sort((a, b) => b.amount.compareTo(a.amount));
case _FinanceSort.byAmountAsc:
list.sort((a, b) => a.amount.compareTo(b.amount));
}
return list;
}
Future<void> _markPaid(FinanceEntry entry) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Ödeme Onayı'),
content: Text(
'${entry.counterpartyName ?? "Bu kayıt"} için '
'${CurrencyFormatter.format(entry.amount, widget.currencyCode)} tutarındaki borcu '
'ödendi olarak işaretlemek istiyor musunuz?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('İptal'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Ödendi'),
),
],
),
);
if (confirmed != true || !mounted) return;
try {
await ClinicFinanceRepository.instance.markPaid(entry.id);
_load();
widget.onPaymentMade();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ödeme kaydedildi.')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
color: AppColors.accent,
onRefresh: () async => _load(),
child: FutureBuilder<List<FinanceEntry>>(
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,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Tekrar Dene'),
),
],
),
);
}
final entries = _sorted(snap.data!);
if (entries.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.receipt_long_outlined,
color: AppColors.inProgress, size: 32),
),
const SizedBox(height: 16),
const Text(
'Kayıt bulunamadı',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
itemCount: entries.length,
itemBuilder: (context, index) {
final entry = entries[index];
final isPending = entry.status == FinanceStatus.pending;
final statusColor =
isPending ? AppColors.pending : AppColors.success;
final statusBg =
isPending ? AppColors.pendingBg : AppColors.successBg;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Material(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: isPending ? () => _markPaid(entry) : null,
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: 44,
height: 44,
decoration: BoxDecoration(
color: statusBg,
borderRadius: BorderRadius.circular(12)),
child: Icon(
isPending
? Icons.hourglass_empty_rounded
: Icons.check_circle_outline,
color: statusColor,
size: 22,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
entry.counterpartyName ??
'Bilinmiyor',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
),
Text(
CurrencyFormatter.format(
entry.amount, widget.currencyCode),
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: statusColor),
),
],
),
if (entry.patientCode != null) ...[
const SizedBox(height: 2),
Text(
'Protokol: ${entry.patientCode}',
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary),
),
],
if (entry.dateCreated != null) ...[
const SizedBox(height: 2),
Text(
_formatDate(entry.dateCreated!),
style: const TextStyle(
fontSize: 12,
color: AppColors.textMuted),
),
],
],
),
),
if (isPending) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: AppColors.pending,
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'Öde',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
],
),
),
),
),
);
},
);
},
),
);
}
String _formatDate(String dateStr) {
try {
final d = DateTime.parse(dateStr);
return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
} catch (_) {
return dateStr;
}
}
}
@@ -0,0 +1,749 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../core/services/realtime_service.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/job.dart';
import '../../../models/job_file.dart';
import '../../../features/shared/job_files_repository.dart';
import '../../../features/shared/job_files_panel.dart';
import '../../../core/services/job_history_service.dart';
import 'clinic_jobs_repository.dart';
class ClinicJobDetailScreen extends ConsumerStatefulWidget {
const ClinicJobDetailScreen({super.key, required this.jobId});
final String jobId;
@override
ConsumerState<ClinicJobDetailScreen> createState() =>
_ClinicJobDetailScreenState();
}
class _ClinicJobDetailScreenState
extends ConsumerState<ClinicJobDetailScreen> {
Job? _job;
String? _loadError;
late Future<List<JobFile>> _filesFuture;
bool _isActing = false;
late UnsubFn _unsub;
@override
void initState() {
super.initState();
_load();
_loadFiles();
_unsub = RealtimeService.instance.watch(
'jobs',
topic: widget.jobId,
onEvent: (_) { if (mounted && !_isActing) _load(); },
);
}
@override
void dispose() {
_unsub();
super.dispose();
}
Future<void> _load() async {
if (mounted) setState(() { _loadError = null; });
try {
final job = await ClinicJobsRepository.instance.getJob(widget.jobId);
if (mounted) setState(() { _job = job; });
} catch (e) {
if (mounted) setState(() { _loadError = e.toString(); });
}
}
void _loadFiles() {
setState(() {
_filesFuture = JobFilesRepository.instance.listForJob(widget.jobId);
});
}
Future<void> _approve(Job job) async {
setState(() => _isActing = true);
try {
final updated = await ClinicJobsRepository.instance.approveAtClinic(job.id, job);
if (mounted) {
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('İş onaylandı.')),
);
}
} catch (e) {
if (mounted) {
setState(() => _isActing = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
}
Future<void> _cancelJob(Job job) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('İşi İptal Et'),
content: const Text('Bu iş geri alınamaz şekilde iptal edilir. Onaylıyor musunuz?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Vazgeç')),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('İptal Et'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _isActing = true);
try {
final updated = await ClinicJobsRepository.instance.cancelJob(job.id, job);
if (mounted) {
setState(() { _job = _job!.copyWith(status: updated.status); _isActing = false; });
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('İş iptal edildi.')));
}
} catch (e) {
if (mounted) {
setState(() => _isActing = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e')));
}
}
}
Future<void> _requestRevision(Job job) async {
final noteController = TextEditingController();
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Revizyon Talebi'),
content: TextField(
controller: noteController,
decoration: const InputDecoration(
labelText: 'Açıklama',
hintText: 'Revizyon sebebini belirtin...',
),
minLines: 3,
maxLines: 5,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('İptal'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Gönder'),
),
],
),
);
if (confirmed != true || !mounted) return;
if (noteController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Lütfen bir açıklama girin.')),
);
return;
}
setState(() => _isActing = true);
try {
final updated = await ClinicJobsRepository.instance.requestRevision(
job.id,
job,
note: noteController.text.trim(),
);
if (mounted) {
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Revizyon talebi gönderildi.')),
);
}
} catch (e) {
if (mounted) {
setState(() => _isActing = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
}
Future<void> _markDelivered(Job job) async {
final noteCtrl = TextEditingController();
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Teslim Alındı'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Bu işi teslim aldığınızı onaylıyor musunuz?'),
const SizedBox(height: 12),
TextField(
controller: noteCtrl,
decoration: const InputDecoration(
labelText: 'Teslimat notu (isteğe bağlı)',
hintText: 'Teslim eden kişi, durum vb...',
isDense: true,
),
maxLines: 2,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('İptal'),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: AppColors.success),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Teslim Alındı'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _isActing = true);
try {
final note = noteCtrl.text.trim().isNotEmpty ? noteCtrl.text.trim() : null;
final updated = await ClinicJobsRepository.instance.markDelivered(job.id, job, note: note);
if (mounted) {
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('İş teslim alındı olarak işaretlendi.')),
);
}
} catch (e) {
if (mounted) {
setState(() => _isActing = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(title: const Text('İş Detayı')),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_job == null && _loadError == null) {
return const Center(child: CircularProgressIndicator(color: AppColors.accent));
}
if (_loadError != null && _job == null) {
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: $_loadError',
style: const TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Tekrar Dene'),
),
],
),
);
}
if (_job == null) return const Center(child: CircularProgressIndicator(color: AppColors.accent));
final job = _job!;
final membership = ref.read(authProvider).activeTenant;
final canDeliver = membership?.canDeliverJobs ?? true;
final canCancel = membership?.canCancelJobs ?? true;
final canManage = !(membership?.isDeliveryOnly ?? false);
return _JobDetailBody(
job: job,
filesFuture: _filesFuture,
isActing: _isActing,
canDeliver: canDeliver,
canManage: canManage,
onApprove: canManage ? () => _approve(job) : () {},
onRevision: canManage ? () => _requestRevision(job) : () {},
onDelivered: () => _markDelivered(job),
onCancel: (canCancel && job.status == JobStatus.pending) ? () => _cancelJob(job) : null,
onFilesRefresh: _loadFiles,
);
}
}
class _JobDetailBody extends StatelessWidget {
const _JobDetailBody({
required this.job,
required this.filesFuture,
required this.isActing,
required this.canDeliver,
required this.canManage,
required this.onApprove,
required this.onRevision,
required this.onDelivered,
required this.onFilesRefresh,
this.onCancel,
});
final Job job;
final Future<List<JobFile>> filesFuture;
final bool isActing;
final bool canDeliver;
final bool canManage;
final VoidCallback onApprove;
final VoidCallback onRevision;
final VoidCallback onDelivered;
final VoidCallback? onCancel;
final VoidCallback onFilesRefresh;
@override
Widget build(BuildContext context) {
final steps = job.stepTemplate;
final currentStepIndex =
job.currentStep != null ? steps.indexOf(job.currentStep!) : -1;
final canApproveOrRevise = canManage &&
job.location == JobLocation.atClinic &&
job.status == JobStatus.inProgress;
final canMarkDelivered = canDeliver && job.status == JobStatus.sent;
return ListView(
padding: const EdgeInsets.all(16),
children: [
// Info card
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: [
// Patient code + status
Row(
children: [
Expanded(
child: Text(
job.patientCode,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
fontWeight: FontWeight.bold,
color: AppColors.textPrimary),
),
),
_StatusBadge(status: job.status),
],
),
const SizedBox(height: 16),
const Divider(height: 1, color: AppColors.border),
const SizedBox(height: 12),
// Patient + Lab
_SectionLabel(title: 'Hasta & Laboratuvar'),
_InfoRow(label: 'Protokol No', value: job.patientCode),
if (job.patientId != null)
_InfoRow(label: 'Hasta ID', value: job.patientId!),
_InfoRow(
label: 'Laboratuvar', value: job.labName ?? 'Bilinmiyor'),
const SizedBox(height: 12),
// Prosthetic
_SectionLabel(title: 'Protez Bilgisi'),
_InfoRow(label: 'Tür', value: job.prostheticType.label),
_InfoRow(label: 'Üye Sayısı', value: '${job.memberCount}'),
if (job.teeth.isNotEmpty)
_InfoRow(label: 'Dişler', value: job.teeth.join(', ')),
if (job.color != null && job.color!.isNotEmpty)
_InfoRow(label: 'Renk', value: job.color!),
if (job.description != null && job.description!.isNotEmpty)
_InfoRow(label: 'Açıklama', value: job.description!),
if (job.dueDate != null)
_InfoRow(label: 'Son Tarih', value: _formatDate(job.dueDate!, withTime: true)),
if (job.price != null)
_InfoRow(
label: 'Fiyat',
value:
'${job.price!.toStringAsFixed(2)} ${job.currency ?? 'TRY'}'),
],
),
),
const SizedBox(height: 16),
// Stepper card
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: [
const Text(
'İş Adımları',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
const SizedBox(height: 16),
_StepperWidget(
steps: steps,
currentStepIndex: currentStepIndex,
historyFuture: JobHistoryService.instance.listForJob(job.id),
),
],
),
),
const SizedBox(height: 24),
// Action buttons
if (isActing)
const Center(
child: CircularProgressIndicator(color: AppColors.accent))
else if (canApproveOrRevise) ...[
FilledButton.icon(
onPressed: onApprove,
icon: const Icon(Icons.check_circle_outline),
label: const Text('Onayla'),
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 50),
backgroundColor: AppColors.success,
),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: onRevision,
icon: const Icon(Icons.replay_outlined),
label: const Text('Revizyon İste'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 50),
foregroundColor: AppColors.pending,
side: const BorderSide(color: AppColors.pending),
),
),
] else if (canMarkDelivered)
FilledButton.icon(
onPressed: onDelivered,
icon: const Icon(Icons.inventory_2_outlined),
label: const Text('Teslim Aldım'),
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 50),
),
),
if (onCancel != null) ...[
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: onCancel,
icon: const Icon(Icons.close_rounded),
label: const Text('İşi İptal Et'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 50),
foregroundColor: AppColors.cancelled,
side: const BorderSide(color: AppColors.cancelled),
),
),
],
const SizedBox(height: 20),
// Files panel
JobFilesPanel(
job: job,
filesFuture: filesFuture,
onRefresh: onFilesRefresh,
),
const SizedBox(height: 12),
Text(
'Oluşturulma: ${_formatDate(job.dateCreated)}',
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
],
);
}
String _formatDate(DateTime d, {bool withTime = false}) {
final s = '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
if (!withTime || (d.hour == 0 && d.minute == 0)) return s;
return '$s ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
}
}
class _StepperWidget extends StatelessWidget {
const _StepperWidget({
required this.steps,
required this.currentStepIndex,
required this.historyFuture,
});
final List<JobStep> steps;
final int currentStepIndex;
final Future<List<JobHistoryEntry>> historyFuture;
@override
Widget build(BuildContext context) {
return FutureBuilder<List<JobHistoryEntry>>(
future: historyFuture,
builder: (ctx, snap) {
final history = snap.data ?? [];
final Map<JobStep, int> revisionCounts = {};
for (final e in history) {
if (e.action == JobHistoryAction.revisionRequested && e.step != null) {
revisionCounts[e.step!] = (revisionCounts[e.step!] ?? 0) + 1;
}
}
return Column(
children: steps.asMap().entries.map((entry) {
final index = entry.key;
final step = entry.value;
final isCompleted = index < currentStepIndex;
final isCurrent = index == currentStepIndex;
final revCount = revisionCounts[step] ?? 0;
Color dotColor;
IconData dotIcon;
if (isCompleted) {
dotColor = AppColors.success;
dotIcon = Icons.check_circle;
} else if (isCurrent) {
dotColor = AppColors.inProgress;
dotIcon = Icons.radio_button_checked;
} else {
dotColor = AppColors.muted;
dotIcon = Icons.radio_button_unchecked;
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
Icon(dotIcon, color: dotColor, size: 24),
if (index < steps.length - 1)
Container(
width: 2,
height: 44,
color: index < currentStepIndex
? AppColors.success.withValues(alpha: 0.35)
: AppColors.border,
),
],
),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 2, bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
step.label,
style: TextStyle(
fontWeight: isCurrent
? FontWeight.bold
: FontWeight.normal,
color: isCompleted
? AppColors.success
: isCurrent
? AppColors.inProgress
: AppColors.textMuted,
fontSize: 15,
),
),
if (revCount > 0) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: AppColors.cancelledBg,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'$revCount revizyon',
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: AppColors.cancelled,
),
),
),
],
],
),
if (isCurrent)
Text(
step.description,
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
),
],
);
}).toList(),
);
},
);
}
}
class _SectionLabel extends StatelessWidget {
const _SectionLabel({required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text(
title,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.accent,
letterSpacing: 0.5),
),
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 110,
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),
),
),
],
),
);
}
}
class _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.status});
final JobStatus status;
@override
Widget build(BuildContext context) {
final color = _color(status);
final bg = _bg(status);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(10),
),
child: Text(
status.label,
style: TextStyle(
color: color,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
);
}
Color _color(JobStatus s) {
switch (s) {
case JobStatus.pending:
return AppColors.pending;
case JobStatus.inProgress:
return AppColors.inProgress;
case JobStatus.sent:
return AppColors.accent;
case JobStatus.delivered:
return AppColors.success;
case JobStatus.cancelled:
return AppColors.cancelled;
}
}
Color _bg(JobStatus s) {
switch (s) {
case JobStatus.pending:
return AppColors.pendingBg;
case JobStatus.inProgress:
return AppColors.inProgressBg;
case JobStatus.sent:
return AppColors.inProgressBg;
case JobStatus.delivered:
return AppColors.successBg;
case JobStatus.cancelled:
return AppColors.cancelledBg;
}
}
}
@@ -0,0 +1,177 @@
import 'dart:async';
import 'package:pocketbase/pocketbase.dart';
import '../../../core/api/pocketbase_client.dart';
import '../../../core/services/job_history_service.dart';
import '../../../models/job.dart';
const _listExpand = 'clinic_tenant_id,lab_tenant_id';
const _detailExpand = 'clinic_tenant_id,lab_tenant_id,patient_id,prosthetic_id';
class ClinicJobsRepository {
ClinicJobsRepository._();
static final instance = ClinicJobsRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<List<Job>> listOutbound(
String clinicTenantId, {
List<String>? statuses,
String? location,
String? filterExtra,
int page = 1,
int limit = 30,
}) async {
final filterParts = ['clinic_tenant_id = "$clinicTenantId"'];
if (statuses != null && statuses.isNotEmpty) {
final statusFilter = statuses.map((s) => 'status = "$s"').join(' || ');
filterParts.add('($statusFilter)');
}
if (location != null) {
filterParts.add('location = "$location"');
}
if (filterExtra != null) {
filterParts.add('($filterExtra)');
}
final result = await _pb.collection('jobs').getList(
page: page,
perPage: limit,
filter: filterParts.join(' && '),
expand: _listExpand,
);
return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated)));
}
Future<Job> getJob(String jobId) async {
final record = await _pb.collection('jobs').getOne(jobId, expand: _detailExpand);
return Job.fromJson(record.toJson());
}
Future<Job> createJob({
required String clinicTenantId,
required String labTenantId,
required String patientCode,
required String prostheticId,
required ProstheticType prostheticType,
required List<String> teeth,
String? patientId,
String? color,
String? description,
String? dueDate,
bool provaRequired = true,
}) async {
final record = await _pb.collection('jobs').create(body: {
'clinic_tenant_id': clinicTenantId,
'lab_tenant_id': labTenantId,
'patient_code': patientCode,
if (patientId != null) 'patient_id': patientId,
'prosthetic_id': prostheticId,
'prosthetic_type': prostheticType.value,
'member_count': teeth.length,
'teeth': teeth,
if (color != null) 'color': color,
if (description != null) 'description': description,
if (dueDate != null) 'due_date': dueDate,
'status': 'pending',
'location': 'at_clinic',
'prova_required': provaRequired,
});
return Job.fromJson(record.toJson());
}
Future<Job> approveAtClinic(String jobId, Job job, {String? note}) async {
final nextStep = job.nextStep;
if (nextStep == null) throw Exception('Bu aşamadan ileri gidilemez.');
final record = await _pb.collection('jobs').update(jobId, body: {
'current_step': nextStep.value,
'location': 'at_lab',
});
final updated = Job.fromJson(record.toJson());
unawaited(JobHistoryService.instance.append(
jobId: jobId,
clinicTenantId: job.clinicTenantId,
labTenantId: job.labTenantId,
action: JobHistoryAction.approved,
step: job.currentStep,
note: note,
));
return updated;
}
Future<Job> requestRevision(String jobId, Job job, {required String note}) async {
final record = await _pb.collection('jobs').update(jobId, body: {
'location': 'at_lab',
});
final updated = Job.fromJson(record.toJson());
unawaited(JobHistoryService.instance.append(
jobId: jobId,
clinicTenantId: job.clinicTenantId,
labTenantId: job.labTenantId,
action: JobHistoryAction.revisionRequested,
step: job.currentStep,
note: note,
));
return updated;
}
Future<Job> markDelivered(String jobId, Job job, {String? note}) async {
final record = await _pb.collection('jobs').update(jobId, body: {
'status': 'delivered',
});
unawaited(JobHistoryService.instance.append(
jobId: jobId,
clinicTenantId: job.clinicTenantId,
labTenantId: job.labTenantId,
action: JobHistoryAction.delivered,
note: note,
));
return Job.fromJson(record.toJson());
}
Future<Job> cancelJob(String jobId, Job job) async {
final record = await _pb.collection('jobs').update(jobId, body: {
'status': 'cancelled',
});
unawaited(JobHistoryService.instance.append(
jobId: jobId,
clinicTenantId: job.clinicTenantId,
labTenantId: job.labTenantId,
action: JobHistoryAction.cancelled,
));
return Job.fromJson(record.toJson());
}
Future<List<Map<String, dynamic>>> listApprovedLabs(String clinicTenantId) async {
final result = await _pb.collection('connections').getList(
filter: 'clinic_tenant_id = "$clinicTenantId" && status = "approved"',
expand: 'lab_tenant_id',
perPage: 100,
);
return result.items.map((r) {
final expand = r.toJson()['expand'] as Map<String, dynamic>?;
return expand?['lab_tenant_id'] as Map<String, dynamic>? ?? {'id': r.data['lab_tenant_id']};
}).toList();
}
Future<List<Job>> listJobsByPatient(String patientId, {int limit = 50}) async {
final result = await _pb.collection('jobs').getList(
filter: 'patient_id = "$patientId"',
perPage: limit,
expand: _listExpand,
);
return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated)));
}
Future<int> countDelivered(String clinicTenantId, {DateTime? from, DateTime? to}) async {
final parts = ['clinic_tenant_id = "$clinicTenantId"', 'status = "delivered"'];
if (from != null) parts.add('updated >= "${_date(from)}"');
if (to != null) parts.add('updated < "${_date(to)}"');
final r = await _pb.collection('jobs').getList(perPage: 1, filter: parts.join(' && '));
return r.totalItems;
}
static String _date(DateTime d) => d.toIso8601String().split('T').first;
}
@@ -0,0 +1,570 @@
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/router/app_router.dart';
import '../../../core/services/realtime_service.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/gradient_app_bar.dart';
import '../../../core/widgets/pill_tabs.dart';
import '../../../models/job.dart';
import 'clinic_jobs_repository.dart';
enum _JobSort { newestFirst, oldestFirst, byDueDate, byType }
const _kSortLabels = [
'Yeniden Eskiye',
'Eskiden Yeniye',
'Vade Tarihine Göre',
'Türe Göre',
];
class ClinicJobsScreen extends ConsumerStatefulWidget {
const ClinicJobsScreen({super.key});
@override
ConsumerState<ClinicJobsScreen> createState() => _ClinicJobsScreenState();
}
class _ClinicJobsScreenState extends ConsumerState<ClinicJobsScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final _searchController = TextEditingController();
String _searchQuery = '';
_JobSort _sort = _JobSort.newestFirst;
@override
void initState() {
super.initState();
_tabController = TabController(length: 5, vsync: this);
_tabController.addListener(() {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_tabController.dispose();
_searchController.dispose();
super.dispose();
}
void _onSearchChanged(String value) {
setState(() => _searchQuery = value);
}
Future<void> _showSortOptions() async {
final result = await showSortSheet(
context,
title: 'Sıralama',
options: _kSortLabels,
current: _sort.index,
);
if (result != null) {
setState(() => _sort = _JobSort.values[result]);
}
}
@override
Widget build(BuildContext context) {
final isSortActive = _sort != _JobSort.newestFirst;
return Scaffold(
backgroundColor: AppColors.background,
appBar: GradientAppBar(
title: 'İşlerim',
category: 'KLİNİK',
searchController: _searchController,
onSearchChanged: _onSearchChanged,
searchHint: 'Protokol, laboratuvar veya tür ara...',
actions: [
IconButton(
onPressed: _showSortOptions,
tooltip: 'Sırala',
icon: Badge(
isLabelVisible: isSortActive,
smallSize: 8,
backgroundColor: AppColors.accent,
child: const Icon(Icons.sort_rounded),
),
),
if (ref.watch(authProvider).activeTenant?.canCreateJobs ?? true)
IconButton(
onPressed: () => context.push(routeClinicJobNew),
tooltip: 'Yeni İş',
icon: const Icon(Icons.add_rounded),
),
],
),
body: Column(
children: [
PillTabs(
tabs: const ['Tümü', 'Onay Bekleyen', 'Lab\'da', 'Teslimat', 'Teslim Alındı'],
selected: _tabController.index,
onSelect: (i) => _tabController.animateTo(i),
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_JobsTab(
statuses: const ['pending', 'in_progress', 'sent', 'delivered'],
searchQuery: _searchQuery,
sort: _sort,
),
_JobsTab(
statuses: const ['in_progress'],
location: 'at_clinic',
searchQuery: _searchQuery,
sort: _sort,
),
_JobsTab(
filterExtra: 'status = "pending" || (status = "in_progress" && location = "at_lab")',
searchQuery: _searchQuery,
sort: _sort,
),
_JobsTab(
statuses: const ['sent'],
searchQuery: _searchQuery,
sort: _sort,
),
_JobsTab(
statuses: const ['delivered'],
searchQuery: _searchQuery,
sort: _sort,
),
],
),
),
],
),
);
}
}
class _JobsTab extends ConsumerStatefulWidget {
const _JobsTab({
this.statuses,
this.location,
this.filterExtra,
required this.searchQuery,
required this.sort,
});
final List<String>? statuses;
final String? location;
final String? filterExtra;
final String searchQuery;
final _JobSort sort;
@override
ConsumerState<_JobsTab> createState() => _JobsTabState();
}
class _JobsTabState extends ConsumerState<_JobsTab> {
final List<Job> _jobs = [];
bool _isLoading = false;
bool _hasMore = true;
int _page = 1;
static const _limit = 20;
String? _error;
late UnsubFn _unsub;
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_load();
_scrollController.addListener(_onScroll);
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
_unsub = RealtimeService.instance.watch(
'jobs',
filter: 'clinic_tenant_id="$tenantId"',
onEvent: (_) { if (mounted) _load(); },
);
}
@override
void dispose() {
_unsub();
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200 &&
!_isLoading &&
_hasMore) {
_loadMore();
}
}
Future<void> _load() async {
if (_isLoading) return;
setState(() {
_isLoading = true;
_error = null;
_page = 1;
_jobs.clear();
_hasMore = true;
});
await _fetch();
}
Future<void> _loadMore() async {
if (_isLoading || !_hasMore) return;
_page++;
await _fetch();
}
Future<void> _fetch() async {
setState(() => _isLoading = true);
try {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
final results = await ClinicJobsRepository.instance.listOutbound(
tenantId,
statuses: widget.statuses,
location: widget.location,
filterExtra: widget.filterExtra,
page: _page,
limit: _limit,
);
setState(() {
_jobs.addAll(results);
_hasMore = results.length == _limit;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
List<Job> get _filtered {
var list = _jobs.toList();
final q = widget.searchQuery.toLowerCase().trim();
if (q.isNotEmpty) {
list = list.where((j) {
return j.patientCode.toLowerCase().contains(q) ||
(j.labName?.toLowerCase().contains(q) ?? false) ||
j.prostheticType.label.toLowerCase().contains(q);
}).toList();
}
switch (widget.sort) {
case _JobSort.newestFirst:
list.sort((a, b) => b.dateCreated.compareTo(a.dateCreated));
case _JobSort.oldestFirst:
list.sort((a, b) => a.dateCreated.compareTo(b.dateCreated));
case _JobSort.byDueDate:
list.sort((a, b) {
if (a.dueDate == null && b.dueDate == null) return 0;
if (a.dueDate == null) return 1;
if (b.dueDate == null) return -1;
return a.dueDate!.compareTo(b.dueDate!);
});
case _JobSort.byType:
list.sort(
(a, b) => a.prostheticType.label.compareTo(b.prostheticType.label));
}
return list;
}
@override
Widget build(BuildContext context) {
if (_error != null && _jobs.isEmpty) {
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: $_error',
style: const TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Tekrar Dene'),
),
],
),
);
}
if (_isLoading && _jobs.isEmpty) {
return const Center(
child: CircularProgressIndicator(color: AppColors.accent));
}
final filtered = _filtered;
if (filtered.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.work_off_outlined,
color: AppColors.inProgress, size: 32),
),
const SizedBox(height: 16),
Text(
widget.searchQuery.isNotEmpty
? 'Sonuç bulunamadı'
: 'Henüz iş yok',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
],
),
);
}
return RefreshIndicator(
color: AppColors.accent,
onRefresh: _load,
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
itemCount:
filtered.length + (_hasMore && widget.searchQuery.isEmpty ? 1 : 0),
itemBuilder: (context, index) {
if (index == filtered.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(color: AppColors.accent)),
);
}
final job = filtered[index];
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _JobListCard(
job: job,
onTap: () => context.push('/clinic/jobs/${job.id}'),
),
);
},
),
);
}
}
class _JobListCard extends StatelessWidget {
const _JobListCard({required this.job, required this.onTap});
final Job job;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final statusColor = _statusColor(job);
final statusBg = _statusBg(job);
final isOverdue =
job.dueDate != null && job.dueDate!.isBefore(DateTime.now());
return Semantics(
label: job.patientCode,
button: true,
excludeSemantics: true,
child: 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: 44,
height: 44,
decoration: BoxDecoration(
color: statusBg,
borderRadius: BorderRadius.circular(12)),
child: Icon(Icons.medical_services_outlined,
color: statusColor, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
job.patientCode,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
),
_StatusBadge(status: job.status, location: job.location),
],
),
const SizedBox(height: 3),
Text(job.prostheticType.label,
style: const TextStyle(
fontSize: 12, color: AppColors.textSecondary)),
if (job.labName != null) ...[
const SizedBox(height: 2),
Text(
job.labName!,
style: const TextStyle(
fontSize: 12, color: AppColors.textMuted),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
if (job.dueDate != null) ...[
const SizedBox(height: 2),
Row(
children: [
Icon(Icons.calendar_today_outlined,
size: 11,
color: isOverdue
? AppColors.cancelled
: AppColors.textMuted),
const SizedBox(width: 3),
Text(
_fmt(job.dueDate!),
style: TextStyle(
fontSize: 12,
color: isOverdue
? AppColors.cancelled
: AppColors.textMuted,
fontWeight: isOverdue
? FontWeight.w600
: FontWeight.normal,
),
),
],
),
],
],
),
),
const SizedBox(width: 8),
const Icon(Icons.chevron_right,
color: AppColors.textMuted, size: 20),
],
),
),
),
),
);
}
String _fmt(DateTime d) =>
'${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
Color _statusColor(Job job) {
if (job.status == JobStatus.inProgress && job.location == JobLocation.atClinic) return AppColors.pending;
switch (job.status) {
case JobStatus.pending: return AppColors.pending;
case JobStatus.inProgress: return AppColors.inProgress;
case JobStatus.sent: return AppColors.accent;
case JobStatus.delivered: return AppColors.success;
case JobStatus.cancelled: return AppColors.cancelled;
}
}
Color _statusBg(Job job) {
if (job.status == JobStatus.inProgress && job.location == JobLocation.atClinic) return AppColors.pendingBg;
switch (job.status) {
case JobStatus.pending: return AppColors.pendingBg;
case JobStatus.inProgress: return AppColors.inProgressBg;
case JobStatus.sent: return AppColors.inProgressBg;
case JobStatus.delivered: return AppColors.successBg;
case JobStatus.cancelled: return AppColors.cancelledBg;
}
}
}
class _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.status, required this.location});
final JobStatus status;
final JobLocation location;
String get _label {
if (status == JobStatus.inProgress && location == JobLocation.atClinic) return 'Onay Bekliyor';
if (status == JobStatus.sent) return 'Teslimat Bekliyor';
return status.label;
}
Color get _color {
if (status == JobStatus.inProgress && location == JobLocation.atClinic) return AppColors.pending;
switch (status) {
case JobStatus.pending: return AppColors.pending;
case JobStatus.inProgress: return AppColors.inProgress;
case JobStatus.sent: return AppColors.accent;
case JobStatus.delivered: return AppColors.success;
case JobStatus.cancelled: return AppColors.cancelled;
}
}
Color get _bg {
if (status == JobStatus.inProgress && location == JobLocation.atClinic) return AppColors.pendingBg;
switch (status) {
case JobStatus.pending: return AppColors.pendingBg;
case JobStatus.inProgress: return AppColors.inProgressBg;
case JobStatus.sent: return AppColors.inProgressBg;
case JobStatus.delivered: return AppColors.successBg;
case JobStatus.cancelled: return AppColors.cancelledBg;
}
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: _bg,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_label,
style: TextStyle(
color: _color,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -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,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),
],
),
),
),
);
}
}
@@ -0,0 +1,685 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/l10n/app_strings.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../core/providers/locale_provider.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/tenant.dart';
import '../../shared/tenant_team_screen.dart';
import '../connections/clinic_connections_screen.dart';
class ClinicSettingsScreen extends ConsumerWidget {
const ClinicSettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authProvider);
final s = ref.watch(stringsProvider);
final profile = auth.profile;
final membership = auth.activeTenant;
final tenant = membership?.tenant;
final canEdit = membership?.isAdmin ?? false;
return Scaffold(
appBar: AppBar(title: Text(s.settings)),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// User card
_SectionHeader(title: s.userInfo),
_UserCard(profile: profile),
const SizedBox(height: 20),
// Clinic info
_SectionHeader(
title: s.clinicInfo,
action: canEdit
? IconButton(
icon: const Icon(Icons.edit_outlined,
size: 18, color: AppColors.accent),
tooltip: s.edit,
onPressed: () => _showEditSheet(context, ref, tenant, s),
)
: null,
),
_InfoCard(children: [
_InfoTile(
icon: Icons.business,
label: s.clinicName,
value: tenant?.companyName ?? '-',
),
_InfoTile(
icon: Icons.category_outlined,
label: s.type,
value: _tenantKindLabel(tenant?.kind, s),
),
_InfoTile(
icon: Icons.star_outline,
label: s.role,
value: _roleLabel(membership?.role, s),
),
]),
const SizedBox(height: 20),
// Connections
if (membership?.showConnections ?? false) ...[
_SectionHeader(title: s.connections),
_InfoCard(children: [
_NavTile(
icon: Icons.link_rounded,
iconColor: AppColors.inProgress,
iconBg: AppColors.inProgressBg,
title: s.labConnections,
subtitle: s.labConnectionsSub,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const ClinicConnectionsScreen()),
),
),
]),
const SizedBox(height: 20),
],
// Other memberships
if (auth.memberships.length > 1) ...[
_SectionHeader(title: s.otherMemberships),
_InfoCard(children: [
for (final m
in auth.memberships.where((m) => m.id != membership?.id))
_NavTile(
icon: Icons.switch_account_outlined,
iconColor: AppColors.inProgress,
iconBg: AppColors.inProgressBg,
title: m.tenant.companyName,
subtitle: _tenantKindLabel(m.tenant.kind, s),
onTap: () {
ref.read(authProvider.notifier).setActiveTenant(m);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(s.tenantSelected(m.tenant.companyName))),
);
},
),
]),
const SizedBox(height: 20),
],
// Team management + Reports
if (membership?.canManageUsers ?? false) ...[
_SectionHeader(title: s.management),
_InfoCard(children: [
_NavTile(
icon: Icons.group_outlined,
iconColor: AppColors.inProgress,
iconBg: AppColors.inProgressBg,
title: s.team,
subtitle: s.teamSub,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const TenantTeamScreen()),
),
),
_NavTile(
icon: Icons.bar_chart_rounded,
iconColor: AppColors.accent,
iconBg: AppColors.inProgressBg,
title: s.reports,
subtitle: s.reportsSub,
onTap: () => context.push(routeClinicReports),
),
_NavTile(
icon: Icons.auto_awesome_outlined,
iconColor: const Color(0xFF7C3AED),
iconBg: const Color(0xFFF3E8FF),
title: s.aiAssistant,
subtitle: s.aiAssistantSub,
onTap: () => context.push(routeClinicAi),
),
]),
const SizedBox(height: 20),
],
// Preferences (language)
_SectionHeader(title: s.preferences),
_InfoCard(children: [
_NavTile(
icon: Icons.language_outlined,
iconColor: AppColors.accent,
iconBg: AppColors.inProgressBg,
title: s.appLanguage,
subtitle: _currentLanguageLabel(ref.watch(localeProvider).languageCode, s),
onTap: () => _showLanguagePicker(context, ref, s),
),
]),
const SizedBox(height: 20),
// Sign out
_SignOutCard(ref: ref, s: s),
const SizedBox(height: 32),
const Center(
child: Text('DLS — Dental Lab System',
style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
),
const SizedBox(height: 4),
const Center(
child: Text('Geliştirici: kovakyazilim.com',
style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
),
const SizedBox(height: 8),
],
),
);
}
void _showEditSheet(BuildContext context, WidgetRef ref, Tenant? tenant, AppStrings s) {
if (tenant == null) return;
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _EditTenantSheet(
tenant: tenant,
s: s,
onSave: (name) async {
await ref
.read(authProvider.notifier)
.updateTenantInfo(tenantId: tenant.id, companyName: name);
},
),
);
}
void _showLanguagePicker(BuildContext context, WidgetRef ref, AppStrings s) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (_) => _LanguagePickerSheet(s: s, ref: ref),
);
}
static String _currentLanguageLabel(String code, AppStrings s) => switch (code) {
'en' => s.languageEnglish,
'ru' => s.languageRussian,
'ar' => s.languageArabic,
'de' => s.languageGerman,
_ => s.languageTurkish,
};
static String _tenantKindLabel(TenantKind? kind, AppStrings s) =>
switch (kind) {
TenantKind.clinic => s.tenantKindClinic,
TenantKind.lab => s.tenantKindLab,
null => '-',
};
static String _roleLabel(TenantRole? role, AppStrings s) => switch (role) {
TenantRole.owner => s.roleOwner,
TenantRole.admin => s.roleAdmin,
TenantRole.technician => s.roleTechnician,
TenantRole.delivery => s.roleDelivery,
TenantRole.finance => s.roleFinance,
TenantRole.doctor => s.roleDoctor,
TenantRole.member => s.roleMember,
null => '-',
};
}
// ── Language picker sheet ─────────────────────────────────────────────────────
class _LanguagePickerSheet extends ConsumerWidget {
const _LanguagePickerSheet({required this.s, required this.ref});
final AppStrings s;
final WidgetRef ref;
@override
Widget build(BuildContext context, WidgetRef _) {
final currentLocale = ref.watch(localeProvider);
final options = [
('tr', '🇹🇷', s.languageTurkish),
('en', '🇬🇧', s.languageEnglish),
('ru', '🇷🇺', s.languageRussian),
('ar', '🇸🇦', s.languageArabic),
('de', '🇩🇪', s.languageGerman),
];
return Container(
decoration: const BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppColors.border,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
Text(
s.languageSelection,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 12),
for (final (code, flag, label) in options)
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
leading: Text(flag, style: const TextStyle(fontSize: 24)),
title: Text(
label,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
),
trailing: currentLocale.languageCode == code
? const Icon(Icons.check_circle_rounded,
color: AppColors.accent)
: null,
onTap: () {
ref.read(localeProvider.notifier).setLocale(Locale(code));
ref.read(authProvider.notifier).updateLanguage(code);
Navigator.pop(context);
},
),
SizedBox(height: MediaQuery.paddingOf(context).bottom + 4),
],
),
);
}
}
// ── Edit sheet ────────────────────────────────────────────────────────────────
class _EditTenantSheet extends StatefulWidget {
const _EditTenantSheet({
required this.tenant,
required this.s,
required this.onSave,
});
final Tenant tenant;
final AppStrings s;
final Future<void> Function(String companyName) onSave;
@override
State<_EditTenantSheet> createState() => _EditTenantSheetState();
}
class _EditTenantSheetState extends State<_EditTenantSheet> {
late final TextEditingController _nameController;
bool _saving = false;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.tenant.companyName);
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
Future<void> _submit() async {
final name = _nameController.text.trim();
if (name.isEmpty) return;
setState(() => _saving = true);
final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
try {
await widget.onSave(name);
navigator.pop();
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('${widget.s.errorPrefix}: $e')));
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final s = widget.s;
return Padding(
padding:
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Container(
decoration: const BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppColors.border,
borderRadius: BorderRadius.circular(2)),
),
),
const SizedBox(height: 16),
Text(s.editClinicInfo,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary)),
const SizedBox(height: 16),
TextFormField(
controller: _nameController,
decoration: InputDecoration(
labelText: s.clinicName,
hintText: s.clinicNameHint,
),
textCapitalization: TextCapitalization.words,
),
const SizedBox(height: 20),
if (_saving)
const Center(
child: CircularProgressIndicator(color: AppColors.accent))
else
FilledButton(
onPressed: _submit,
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 48)),
child: Text(s.save),
),
SizedBox(height: MediaQuery.paddingOf(context).bottom + 4),
],
),
),
);
}
}
// ── Reusable UI pieces ────────────────────────────────────────────────────────
class _UserCard extends StatelessWidget {
const _UserCard({required this.profile});
final dynamic profile;
@override
Widget build(BuildContext context) {
final displayName = (profile?.displayName?.isNotEmpty == true)
? profile!.displayName as String
: 'Kullanıcı';
final initial = (profile?.displayName?.isNotEmpty == true
? (profile!.displayName as String)[0]
: (profile?.email as String?)?[0] ?? '?')
.toUpperCase();
return 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: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: AppColors.inProgressBg,
borderRadius: BorderRadius.circular(14)),
child: Center(
child: Text(initial,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.inProgress)),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(displayName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary)),
const SizedBox(height: 2),
Text(profile?.email as String? ?? '',
style: const TextStyle(
fontSize: 13, color: AppColors.textSecondary)),
],
),
),
],
),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.title, this.action});
final String title;
final Widget? action;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.accent,
letterSpacing: 0.3),
),
),
if (action != null) action!,
],
),
);
}
}
class _InfoCard extends StatelessWidget {
const _InfoCard({required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) {
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: [
for (int i = 0; i < children.length; i++) ...[
children[i],
if (i < children.length - 1)
const Divider(
height: 1, indent: 16, endIndent: 16, color: AppColors.border),
],
],
),
);
}
}
class _InfoTile extends StatelessWidget {
const _InfoTile(
{required this.icon, required this.label, required this.value});
final IconData icon;
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(icon, size: 18, color: AppColors.textSecondary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(
fontSize: 11, color: AppColors.textMuted)),
const SizedBox(height: 2),
Text(value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary)),
],
),
),
],
),
);
}
}
class _NavTile extends StatelessWidget {
const _NavTile({
required this.icon,
required this.iconColor,
required this.iconBg,
required this.title,
required this.onTap,
this.subtitle,
});
final IconData icon;
final Color iconColor;
final Color iconBg;
final String title;
final String? subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: iconBg, borderRadius: BorderRadius.circular(9)),
child: Icon(icon, color: iconColor, size: 18),
),
title: Text(title,
style: const TextStyle(
fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
subtitle: subtitle != null
? Text(subtitle!,
style: const TextStyle(color: AppColors.textSecondary))
: null,
trailing:
const Icon(Icons.chevron_right, color: AppColors.textSecondary),
onTap: onTap,
);
}
}
class _SignOutCard extends StatelessWidget {
const _SignOutCard({required this.ref, required this.s});
final WidgetRef ref;
final AppStrings s;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.cancelledBg),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 12,
offset: const Offset(0, 4))
],
),
child: ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.cancelledBg,
borderRadius: BorderRadius.circular(9)),
child: const Icon(Icons.logout,
color: AppColors.cancelled, size: 18),
),
title: Text(s.signOut,
style: const TextStyle(
color: AppColors.cancelled, fontWeight: FontWeight.w600)),
onTap: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(s.signOutTitle),
content: Text(s.signOutConfirm),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(s.cancel)),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: AppColors.cancelled),
child: Text(s.signOut),
),
],
),
);
if (confirmed == true) {
await ref.read(authProvider.notifier).signOut();
}
},
),
);
}
}
@@ -0,0 +1,581 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/connection.dart';
import '../../../models/job.dart';
import 'connection_stats_repository.dart';
class ConnectionDetailScreen extends StatefulWidget {
const ConnectionDetailScreen({
super.key,
required this.connection,
required this.labTenantId,
});
final Connection connection;
final String labTenantId;
@override
State<ConnectionDetailScreen> createState() => _ConnectionDetailScreenState();
}
class _ConnectionDetailScreenState extends State<ConnectionDetailScreen> {
late Future<ConnectionStats> _future;
@override
void initState() {
super.initState();
_load();
}
void _load() {
setState(() {
_future = ConnectionStatsRepository.instance.fetchStats(
labTenantId: widget.labTenantId,
clinicTenantId: widget.connection.clinicTenantId,
);
});
}
@override
Widget build(BuildContext context) {
final conn = widget.connection;
final clinicName = conn.clinicName ?? 'Klinik';
return Scaffold(
backgroundColor: AppColors.background,
body: CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
expandedHeight: 140,
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF0F172A), AppColors.primary],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 48, 20, 16),
child: Row(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white.withValues(alpha: 0.2)),
),
child: const Icon(Icons.local_hospital_outlined, color: Colors.white, size: 26),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
clinicName,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
_StatusBadge(status: conn.status),
],
),
),
],
),
),
),
),
),
),
SliverToBoxAdapter(
child: FutureBuilder<ConnectionStats>(
future: _future,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 80),
child: Center(child: CircularProgressIndicator(color: AppColors.accent)),
);
}
if (snap.hasError) {
return Padding(
padding: const EdgeInsets.all(32),
child: 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), textAlign: TextAlign.center),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh_rounded, size: 16),
label: const Text('Tekrar Dene'),
),
],
),
),
);
}
final stats = snap.data!;
return _StatsBody(stats: stats);
},
),
),
],
),
);
}
}
// ── Stats body ────────────────────────────────────────────────────────────────
class _StatsBody extends StatelessWidget {
const _StatsBody({required this.stats});
final ConnectionStats stats;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// KPI row
Row(
children: [
Expanded(child: _KpiCard(label: 'Toplam İş', value: '${stats.totalJobs}', icon: Icons.work_outline_rounded, color: AppColors.accent)),
const SizedBox(width: 10),
Expanded(child: _KpiCard(label: 'Aktif', value: '${stats.activeJobs}', icon: Icons.pending_outlined, color: AppColors.inProgress)),
const SizedBox(width: 10),
Expanded(child: _KpiCard(label: 'Teslim', value: '${stats.deliveredJobs}', icon: Icons.check_circle_outline, color: AppColors.success)),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(child: _KpiCard(label: 'Bu Ay', value: '${stats.thisMonthJobs}', icon: Icons.calendar_month_outlined, color: AppColors.accent)),
const SizedBox(width: 10),
Expanded(child: _KpiCard(label: 'Geçen Ay', value: '${stats.lastMonthJobs}', icon: Icons.history_rounded, color: AppColors.textSecondary)),
const SizedBox(width: 10),
Expanded(child: _KpiCard(label: 'İptal', value: '${stats.cancelledJobs}', icon: Icons.cancel_outlined, color: AppColors.cancelled)),
],
),
const SizedBox(height: 16),
// Revenue
_SectionTitle('Finans'),
const SizedBox(height: 8),
_RevenueCard(stats: stats),
const SizedBox(height: 16),
// Prosthetic type breakdown
if (stats.byType.isNotEmpty) ...[
_SectionTitle('Ürün Tipi Dağılımı'),
const SizedBox(height: 8),
_TypeBreakdownCard(byType: stats.byType, total: stats.totalJobs),
const SizedBox(height: 16),
],
// Monthly trend
_SectionTitle('Aylık Karşılaştırma'),
const SizedBox(height: 8),
_MonthCompareCard(stats: stats),
const SizedBox(height: 16),
// Recent jobs
if (stats.recentJobs.isNotEmpty) ...[
_SectionTitle('Son İşler'),
const SizedBox(height: 8),
_RecentJobsCard(jobs: stats.recentJobs),
const SizedBox(height: 16),
],
const SizedBox(height: 32),
],
),
);
}
}
// ── KPI card ──────────────────────────────────────────────────────────────────
class _KpiCard extends StatelessWidget {
const _KpiCard({required this.label, required this.value, required this.icon, required this.color});
final String label;
final String value;
final IconData icon;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 6, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: color),
const SizedBox(height: 8),
Text(value, style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800, color: color)),
const SizedBox(height: 2),
Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textMuted, fontWeight: FontWeight.w500)),
],
),
);
}
}
// ── Revenue card ──────────────────────────────────────────────────────────────
class _RevenueCard extends StatelessWidget {
const _RevenueCard({required this.stats});
final ConnectionStats stats;
@override
Widget build(BuildContext context) {
final collected = stats.totalRevenue - stats.pendingRevenue;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
),
child: Column(
children: [
_RevRow(
label: 'Toplam Gelir',
value: _fmt(stats.totalRevenue),
color: AppColors.textPrimary,
bold: true,
),
const Divider(height: 20),
_RevRow(label: 'Tahsil Edilen', value: _fmt(collected), color: AppColors.success),
const SizedBox(height: 8),
_RevRow(label: 'Bekleyen Alacak', value: _fmt(stats.pendingRevenue), color: AppColors.pending),
],
),
);
}
String _fmt(double v) => '${v.toStringAsFixed(0)} TL';
}
class _RevRow extends StatelessWidget {
const _RevRow({required this.label, required this.value, required this.color, this.bold = false});
final String label;
final String value;
final Color color;
final bool bold;
@override
Widget build(BuildContext context) {
final style = TextStyle(
fontSize: bold ? 15 : 14,
fontWeight: bold ? FontWeight.w700 : FontWeight.w500,
color: color,
);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: style.copyWith(color: bold ? AppColors.textPrimary : AppColors.textSecondary)),
Text(value, style: style),
],
);
}
}
// ── Type breakdown ────────────────────────────────────────────────────────────
class _TypeBreakdownCard extends StatelessWidget {
const _TypeBreakdownCard({required this.byType, required this.total});
final Map<String, int> byType;
final int total;
@override
Widget build(BuildContext context) {
final sorted = byType.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
),
child: Column(
children: [
for (int i = 0; i < sorted.length; i++) ...[
if (i > 0) const SizedBox(height: 10),
_TypeBar(
label: _typeLabel(sorted[i].key),
count: sorted[i].value,
total: total,
),
],
],
),
);
}
String _typeLabel(String key) => switch (key) {
'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',
_ => 'Diğer',
};
}
class _TypeBar extends StatelessWidget {
const _TypeBar({required this.label, required this.count, required this.total});
final String label;
final int count;
final int total;
@override
Widget build(BuildContext context) {
final pct = total > 0 ? count / total : 0.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.textPrimary)),
Text('$count adet · ${(pct * 100).toStringAsFixed(0)}%',
style: const TextStyle(fontSize: 12, color: AppColors.textMuted)),
],
),
const SizedBox(height: 5),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: pct,
minHeight: 7,
backgroundColor: AppColors.border,
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.accent),
),
),
],
);
}
}
// ── Monthly compare ───────────────────────────────────────────────────────────
class _MonthCompareCard extends StatelessWidget {
const _MonthCompareCard({required this.stats});
final ConnectionStats stats;
@override
Widget build(BuildContext context) {
final thisMonth = stats.thisMonthJobs;
final lastMonth = stats.lastMonthJobs;
final maxBar = (thisMonth > lastMonth ? thisMonth : lastMonth).clamp(1, 999);
final trend = lastMonth == 0
? null
: ((thisMonth - lastMonth) / lastMonth * 100).toStringAsFixed(0);
final isUp = thisMonth >= lastMonth;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trend != null) ...[
Row(
children: [
Icon(
isUp ? Icons.trending_up_rounded : Icons.trending_down_rounded,
size: 18,
color: isUp ? AppColors.success : AppColors.cancelled,
),
const SizedBox(width: 6),
Text(
isUp ? '+$trend% geçen aya göre' : '$trend% geçen aya göre',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isUp ? AppColors.success : AppColors.cancelled,
),
),
],
),
const SizedBox(height: 12),
],
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_BarColumn(label: 'Geçen Ay', count: lastMonth, maxCount: maxBar, color: AppColors.border),
const SizedBox(width: 16),
_BarColumn(label: 'Bu Ay', count: thisMonth, maxCount: maxBar, color: AppColors.accent),
],
),
],
),
);
}
}
class _BarColumn extends StatelessWidget {
const _BarColumn({required this.label, required this.count, required this.maxCount, required this.color});
final String label;
final int count;
final int maxCount;
final Color color;
@override
Widget build(BuildContext context) {
final height = maxCount > 0 ? (count / maxCount * 80).clamp(4.0, 80.0) : 4.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text('$count', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: color == AppColors.border ? AppColors.textSecondary : AppColors.accent)),
const SizedBox(height: 4),
Container(
width: 40,
height: height,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 4),
Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textMuted)),
],
);
}
}
// ── Recent jobs ───────────────────────────────────────────────────────────────
class _RecentJobsCard extends StatelessWidget {
const _RecentJobsCard({required this.jobs});
final List<Job> jobs;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
),
child: Column(
children: [
for (int i = 0; i < jobs.length; i++) ...[
if (i > 0) const Divider(height: 1),
_RecentJobRow(job: jobs[i]),
],
],
),
);
}
}
class _RecentJobRow extends StatelessWidget {
const _RecentJobRow({required this.job});
final Job job;
@override
Widget build(BuildContext context) {
final color = job.status == JobStatus.delivered ? AppColors.success : AppColors.inProgress;
final bg = job.status == JobStatus.delivered ? AppColors.successBg : AppColors.inProgressBg;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(10)),
child: Center(child: Icon(Icons.assignment_outlined, size: 18, color: color)),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(job.patientCode, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
Text(job.prostheticType.label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
child: Text(job.status.label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)),
),
],
),
);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
class _SectionTitle extends StatelessWidget {
const _SectionTitle(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Text(text, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.textMuted, letterSpacing: 0.5));
}
}
class _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.status});
final ConnectionStatus status;
@override
Widget build(BuildContext context) {
final (label, color) = switch (status) {
ConnectionStatus.approved => ('Onaylı', AppColors.success),
ConnectionStatus.pending => ('Bekliyor', AppColors.pending),
ConnectionStatus.rejected => ('Reddedildi', AppColors.cancelled),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)),
);
}
}
@@ -0,0 +1,124 @@
import 'package:pocketbase/pocketbase.dart';
import '../../../core/api/pocketbase_client.dart';
import '../../../models/job.dart';
class ConnectionStats {
const ConnectionStats({
required this.totalJobs,
required this.byStatus,
required this.byType,
required this.totalRevenue,
required this.pendingRevenue,
required this.thisMonthJobs,
required this.lastMonthJobs,
required this.revisionCount,
required this.recentJobs,
});
final int totalJobs;
final Map<String, int> byStatus; // 'in_progress', 'sent', 'delivered', 'cancelled'
final Map<String, int> byType; // prosthetic_type -> count
final double totalRevenue;
final double pendingRevenue;
final int thisMonthJobs;
final int lastMonthJobs;
final int revisionCount;
final List<Job> recentJobs;
int get deliveredJobs => byStatus['delivered'] ?? 0;
int get activeJobs => (byStatus['in_progress'] ?? 0) + (byStatus['sent'] ?? 0);
int get cancelledJobs => byStatus['cancelled'] ?? 0;
double get revisionRate => totalJobs > 0 ? revisionCount / totalJobs * 100 : 0;
double get completionRate => totalJobs > 0 ? deliveredJobs / totalJobs * 100 : 0;
}
class ConnectionStatsRepository {
ConnectionStatsRepository._();
static final instance = ConnectionStatsRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<ConnectionStats> fetchStats({
required String labTenantId,
required String clinicTenantId,
}) async {
final now = DateTime.now();
final thisMonthStart = DateTime(now.year, now.month, 1);
final lastMonthStart = DateTime(now.year, now.month - 1, 1);
final lastMonthEnd = thisMonthStart.subtract(const Duration(seconds: 1));
final filter = 'lab_tenant_id = "$labTenantId" && clinic_tenant_id = "$clinicTenantId"';
final results = await Future.wait([
_pb.collection('jobs').getList(
filter: filter,
perPage: 500,
expand: 'clinic_tenant_id,lab_tenant_id',
),
_pb.collection('finance_entries').getList(
filter: 'tenant_id = "$labTenantId" && job_id.clinic_tenant_id = "$clinicTenantId"',
perPage: 500,
),
]);
final jobsResult = results[0];
final financeResult = results[1];
final allJobs = jobsResult.items
.map((r) => Job.fromJson(r.toJson()))
.toList();
// Status breakdown
final byStatus = <String, int>{};
final byType = <String, int>{};
int thisMonthJobs = 0;
int lastMonthJobs = 0;
int revisionCount = 0;
for (final job in allJobs) {
// Status
final s = job.status.value;
byStatus[s] = (byStatus[s] ?? 0) + 1;
// Type
final t = job.prostheticType.value;
byType[t] = (byType[t] ?? 0) + 1;
// Monthly
final created = job.dateCreated;
if (created.isAfter(thisMonthStart)) thisMonthJobs++;
else if (created.isAfter(lastMonthStart) && created.isBefore(lastMonthEnd)) lastMonthJobs++;
// Revision
if (job.status == JobStatus.inProgress && job.currentStep == null) revisionCount++;
}
// Finance
double totalRevenue = 0;
double pendingRevenue = 0;
for (final r in financeResult.items) {
final j = r.toJson();
final amount = (j['amount'] as num?)?.toDouble() ?? 0;
totalRevenue += amount;
if (j['status'] == 'pending') pendingRevenue += amount;
}
// Recent jobs (last 5 delivered or sent)
final recent = allJobs
.where((j) => j.status == JobStatus.delivered || j.status == JobStatus.sent)
.toList()
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated));
return ConnectionStats(
totalJobs: allJobs.length,
byStatus: byStatus,
byType: byType,
totalRevenue: totalRevenue,
pendingRevenue: pendingRevenue,
thisMonthJobs: thisMonthJobs,
lastMonthJobs: lastMonthJobs,
revisionCount: revisionCount,
recentJobs: recent.take(5).toList(),
);
}
}
@@ -0,0 +1,30 @@
import 'package:pocketbase/pocketbase.dart';
import '../../../core/api/pocketbase_client.dart';
import '../../../models/connection.dart';
class LabConnectionsRepository {
LabConnectionsRepository._();
static final instance = LabConnectionsRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<List<Connection>> listConnections(String labTenantId) async {
final result = await _pb.collection('connections').getList(
filter: 'lab_tenant_id = "$labTenantId"',
expand: 'clinic_tenant_id,lab_tenant_id',
perPage: 100,
);
return (result.items.map((r) => Connection.fromJson(r.toJson())).toList()
..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? '')));
}
Future<Connection> respondToRequest({
required String connectionId,
required bool approve,
}) async {
final record = await _pb.collection('connections').update(connectionId, body: {
'status': approve ? 'approved' : 'rejected',
});
return Connection.fromJson(record.toJson());
}
}
@@ -0,0 +1,453 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/gradient_app_bar.dart';
import '../../../models/connection.dart';
import 'connection_detail_screen.dart';
import 'lab_connections_repository.dart';
class LabConnectionsScreen extends ConsumerStatefulWidget {
const LabConnectionsScreen({super.key});
@override
ConsumerState<LabConnectionsScreen> createState() =>
_LabConnectionsScreenState();
}
class _LabConnectionsScreenState extends ConsumerState<LabConnectionsScreen> {
late Future<List<Connection>> _future;
final _searchController = TextEditingController();
String _searchQuery = '';
@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 = LabConnectionsRepository.instance.listConnections(tenantId);
});
}
Future<void> _respond(String connectionId, bool approve) async {
try {
await LabConnectionsRepository.instance.respondToRequest(
connectionId: connectionId,
approve: approve,
);
_load();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
approve ? 'Bağlantı onaylandı' : 'Bağlantı reddedildi'),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
}
Color _statusColor(ConnectionStatus status) {
return switch (status) {
ConnectionStatus.pending => AppColors.pending,
ConnectionStatus.approved => AppColors.success,
ConnectionStatus.rejected => AppColors.cancelled,
};
}
Color _statusBg(ConnectionStatus status) {
return switch (status) {
ConnectionStatus.pending => AppColors.pendingBg,
ConnectionStatus.approved => AppColors.successBg,
ConnectionStatus.rejected => AppColors.cancelledBg,
};
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: GradientAppBar(
title: 'Bağlantılar',
category: 'LABORATUVAR',
searchController: _searchController,
onSearchChanged: (v) => setState(() => _searchQuery = v),
searchHint: 'Klinik adı ara...',
),
body: RefreshIndicator(
color: AppColors.accent,
onRefresh: () async => _load(),
child: FutureBuilder<List<Connection>>(
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,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Tekrar Dene'),
),
],
),
);
}
final allConnections = snap.data!;
final q = _searchQuery.toLowerCase().trim();
final connections = q.isEmpty
? allConnections
: allConnections.where((c) =>
(c.clinicName ?? '').toLowerCase().contains(q)).toList();
if (allConnections.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.link_off,
color: AppColors.inProgress, size: 32),
),
const SizedBox(height: 16),
const Text(
'Henüz bağlantı yok',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
const SizedBox(height: 8),
const Text(
'Kliniklerden gelen istekler burada görünür.',
style: TextStyle(
color: AppColors.textSecondary, fontSize: 13),
textAlign: TextAlign.center,
),
],
),
);
}
final pending = connections
.where((c) => c.status == ConnectionStatus.pending)
.toList();
final others = connections
.where((c) => c.status != ConnectionStatus.pending)
.toList();
return ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
children: [
if (pending.isNotEmpty) ...[
_SectionHeader(
label: 'Bekleyen İstekler',
count: pending.length,
countColor: AppColors.pending,
countBg: AppColors.pendingBg,
),
const SizedBox(height: 8),
...pending.map((c) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _ConnectionCard(
connection: c,
statusColor: _statusColor(c.status),
statusBg: _statusBg(c.status),
onApprove: () => _respond(c.id, true),
onReject: () => _respond(c.id, false),
),
)),
const SizedBox(height: 8),
],
if (others.isNotEmpty) ...[
_SectionHeader(
label: 'Bağlantılar',
count: others.length,
countColor: AppColors.textSecondary,
countBg: AppColors.surfaceVariant,
),
const SizedBox(height: 8),
...others.map((c) {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _ConnectionCard(
connection: c,
statusColor: _statusColor(c.status),
statusBg: _statusBg(c.status),
onTap: c.status == ConnectionStatus.approved
? () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ConnectionDetailScreen(
connection: c,
labTenantId: tenantId,
),
),
)
: null,
),
);
}),
],
],
);
},
),
),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({
required this.label,
required this.count,
required this.countColor,
required this.countBg,
});
final String label;
final int count;
final Color countColor;
final Color countBg;
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(
label,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.accent,
letterSpacing: 0.3,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: countBg,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'$count',
style: TextStyle(
color: countColor,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
);
}
}
class _ConnectionCard extends StatefulWidget {
const _ConnectionCard({
required this.connection,
required this.statusColor,
required this.statusBg,
this.onApprove,
this.onReject,
this.onTap,
});
final Connection connection;
final Color statusColor;
final Color statusBg;
final VoidCallback? onApprove;
final VoidCallback? onReject;
final VoidCallback? onTap;
@override
State<_ConnectionCard> createState() => _ConnectionCardState();
}
class _ConnectionCardState extends State<_ConnectionCard> {
bool _loading = false;
Future<void> _act(VoidCallback? cb) async {
if (cb == null) return;
setState(() => _loading = true);
cb();
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) setState(() => _loading = false);
}
String _formatDate(String? raw) {
if (raw == null) return '';
try {
final dt = DateTime.parse(raw);
return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}';
} catch (_) {
return '';
}
}
@override
Widget build(BuildContext context) {
final c = widget.connection;
final isPending = c.status == ConnectionStatus.pending;
return Material(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: widget.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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: widget.statusBg,
borderRadius: BorderRadius.circular(12)),
child: Icon(Icons.business_outlined,
color: widget.statusColor, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
c.clinicName ?? 'Klinik',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
if (c.dateCreated != null) ...[
const SizedBox(height: 2),
Text(
_formatDate(c.dateCreated),
style: const TextStyle(
color: AppColors.textMuted, fontSize: 12),
),
],
],
),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: widget.statusBg,
borderRadius: BorderRadius.circular(8),
),
child: Text(
c.status.label,
style: TextStyle(
color: widget.statusColor,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
if (widget.onTap != null) ...[
const SizedBox(width: 4),
const Icon(Icons.chevron_right_rounded,
size: 18, color: AppColors.textMuted),
],
],
),
if (isPending) ...[
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed:
_loading ? null : () => _act(widget.onReject),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.cancelled,
side: const BorderSide(color: AppColors.cancelled)),
child: const Text('Reddet'),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed:
_loading ? null : () => _act(widget.onApprove),
style: FilledButton.styleFrom(
backgroundColor: AppColors.success),
child: _loading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Text('Onayla'),
),
),
],
),
],
],
),
),
),
);
}
}
@@ -0,0 +1,883 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/tooth_logo.dart';
import '../../../core/services/realtime_service.dart';
import '../../../models/job.dart';
import '../jobs/lab_jobs_repository.dart';
class LabDashboardScreen extends ConsumerStatefulWidget {
const LabDashboardScreen({super.key});
@override
ConsumerState<LabDashboardScreen> createState() => _LabDashboardScreenState();
}
class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
late Future<_DashboardData> _future;
bool _acceptingAll = false;
late UnsubFn _unsub;
@override
void initState() {
super.initState();
_load();
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
_unsub = RealtimeService.instance.watch(
'jobs',
filter: "lab_tenant_id='$tenantId'",
onEvent: (_) { if (mounted) _load(); },
);
}
@override
void dispose() {
_unsub();
super.dispose();
}
void _load() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
final now = DateTime.now();
final thisMonthStart = DateTime(now.year, now.month, 1);
final lastMonthStart = DateTime(now.year, now.month - 1, 1);
setState(() {
_future = Future.wait([
Future.wait<List<Job>>([
LabJobsRepository.instance.listInbound(tenantId, status: 'pending'),
LabJobsRepository.instance.listInProgress(tenantId),
LabJobsRepository.instance.listInProgress(tenantId, location: 'at_lab'),
LabJobsRepository.instance.listInProgress(tenantId, location: 'at_clinic'),
LabJobsRepository.instance.listInbound(tenantId, status: 'sent', limit: 200),
LabJobsRepository.instance.listInbound(tenantId, status: 'delivered', limit: 200),
]),
LabJobsRepository.instance.countDelivered(tenantId, from: thisMonthStart),
LabJobsRepository.instance.countDelivered(tenantId, from: lastMonthStart, to: thisMonthStart),
]).then((r) {
final jobs = r[0] as List<List<Job>>;
return _DashboardData(
pendingJobs: jobs[0],
inProgressJobs: jobs[1],
atLabJobs: jobs[2],
atClinicJobs: jobs[3],
sentCount: jobs[4].length,
deliveredCount: jobs[5].length,
thisMonthDelivered: r[1] as int,
lastMonthDelivered: r[2] as int,
);
});
});
}
Future<void> _bulkAccept() async {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() => _acceptingAll = true);
try {
await LabJobsRepository.instance.bulkAcceptPending(tenantId);
_load();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e'), behavior: SnackBarBehavior.floating),
);
}
} finally {
if (mounted) setState(() => _acceptingAll = false);
}
}
@override
Widget build(BuildContext context) {
final companyName = ref.watch(authProvider).activeTenant?.tenant.companyName ?? '';
return Scaffold(
backgroundColor: AppColors.background,
body: LayoutBuilder(
builder: (context, constraints) {
const maxContent = 1040.0;
final hPad = constraints.maxWidth > maxContent
? (constraints.maxWidth - maxContent) / 2
: 16.0;
return RefreshIndicator(
color: AppColors.accent,
onRefresh: () async => _load(),
child: FutureBuilder<_DashboardData>(
future: _future,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return _DashboardSkeleton(companyName: companyName, hPad: hPad);
}
if (snap.hasError) return _ErrorBody(onRetry: _load);
final data = snap.data!;
final isDesktop = MediaQuery.sizeOf(ctx).width > AppLayout.sidebarBreakpoint;
return CustomScrollView(
slivers: [
_DashboardHeader(companyName: companyName),
if (isDesktop)
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
sliver: SliverToBoxAdapter(
child: _StatsRow(
pending: data.pendingJobs.length,
inProgress: data.inProgressJobs.length,
sent: data.sentCount,
delivered: data.deliveredCount,
),
),
),
if (data.pendingJobs.isNotEmpty)
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
sliver: SliverToBoxAdapter(
child: _AcceptAllBanner(
count: data.pendingJobs.length,
loading: _acceptingAll,
onTap: _bulkAccept,
).animate().fadeIn(duration: 300.ms).slideY(begin: 0.1, end: 0),
),
),
if (isDesktop) ...[
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
sliver: SliverToBoxAdapter(
child: _MonthlyReportSection(data: data)
.animate().fadeIn(duration: 300.ms).slideY(begin: 0.08, end: 0),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
sliver: SliverToBoxAdapter(
child: _GamificationRow(data: data)
.animate().fadeIn(duration: 300.ms, delay: 60.ms).slideY(begin: 0.08, end: 0),
),
),
],
// ── Yapılacaklar (at_lab) ────────────────────────────
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
sliver: SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Yapılacaklar', style: Theme.of(context).textTheme.titleMedium),
TextButton(
onPressed: () => context.go(routeLabJobsAll),
style: TextButton.styleFrom(foregroundColor: AppColors.accent, padding: const EdgeInsets.symmetric(horizontal: 8)),
child: const Text('Tümünü Gör'),
),
],
),
),
),
if (data.atLabJobs.isEmpty)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(16, 4, 16, 0),
child: _EmptySection(message: 'Yapılacak iş yok'),
),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
sliver: SliverList.separated(
itemCount: data.atLabJobs.take(5).length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (ctx, i) => _JobCard(job: data.atLabJobs[i])
.animate(delay: (i * 60).ms)
.fadeIn(duration: 300.ms)
.slideY(begin: 0.12, end: 0),
),
),
// ── Klinikte Onay Bekliyor (at_clinic) ───────────────
if (data.atClinicJobs.isNotEmpty) ...[
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
sliver: SliverToBoxAdapter(
child: Text('Klinikte Onay Bekliyor', style: Theme.of(context).textTheme.titleMedium),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
sliver: SliverList.separated(
itemCount: data.atClinicJobs.take(5).length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (ctx, i) => _JobCard(job: data.atClinicJobs[i])
.animate(delay: (i * 60).ms)
.fadeIn(duration: 300.ms)
.slideY(begin: 0.12, end: 0),
),
),
],
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
);
},
),
);
},
),
);
}
}
class _DashboardHeader extends StatelessWidget {
const _DashboardHeader({required this.companyName});
final String companyName;
// Must stay in sync with _DesktopSidebar.headerHeight in app_router.dart
static const double _desktopToolbarHeight = 64;
@override
Widget build(BuildContext context) {
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
if (isDesktop) {
return SliverAppBar(
pinned: true,
toolbarHeight: _desktopToolbarHeight,
backgroundColor: AppColors.surface,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Genel Bakış', style: TextStyle(fontSize: 11, color: AppColors.textSecondary.withValues(alpha: 0.8), letterSpacing: 0.3)),
const Text('Bugünkü Durum', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
],
),
),
actions: [
IconButton(
onPressed: () => context.go(routeLabSettings),
icon: const Icon(Icons.settings_outlined, color: AppColors.textSecondary, size: 22),
),
const SizedBox(width: 8),
],
);
}
return SliverAppBar(
pinned: true,
expandedHeight: 148,
backgroundColor: AppColors.primary,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
systemOverlayStyle: SystemUiOverlayStyle.light,
centerTitle: false,
leadingWidth: 60,
leading: Padding(
padding: const EdgeInsets.only(left: 16, top: 8, bottom: 8),
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: const Center(child: ToothLogo(size: 20, color: Colors.white)),
),
),
titleSpacing: 8,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('DLS', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 1.5)),
Text(companyName, style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w700), maxLines: 1, overflow: TextOverflow.ellipsis),
],
),
actions: [
IconButton(
onPressed: () => context.go(routeLabSettings),
icon: const Icon(Icons.settings_outlined, color: Colors.white, size: 22),
),
],
flexibleSpace: FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.primary, AppColors.accent],
),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text('Genel Bakış', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 12, fontWeight: FontWeight.w500, letterSpacing: 0.5)),
const SizedBox(height: 4),
const Text('Bugünkü Durum', style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.w800, letterSpacing: -0.5)),
],
),
),
),
),
);
}
}
class _StatsRow extends StatelessWidget {
const _StatsRow({
required this.pending,
required this.inProgress,
required this.sent,
required this.delivered,
});
final int pending;
final int inProgress;
final int sent;
final int delivered;
@override
Widget build(BuildContext context) {
final isWideDesktop = MediaQuery.sizeOf(context).width >= AppLayout.wideBreakpoint;
final pendingCard = _StatCard(label: 'Bekleyen', value: '$pending', icon: Icons.hourglass_top_rounded, color: AppColors.pending, bgColor: AppColors.pendingBg)
.animate().fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
final inProgressCard = _StatCard(label: 'Devam Eden', value: '$inProgress', icon: Icons.autorenew_rounded, color: AppColors.inProgress, bgColor: AppColors.inProgressBg)
.animate(delay: 80.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
if (isWideDesktop) {
final sentCard = _StatCard(label: 'Klinik\'te', value: '$sent', icon: Icons.local_hospital_outlined, color: AppColors.accent, bgColor: AppColors.inProgressBg)
.animate(delay: 120.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
final deliveredCard = _StatCard(label: 'Tamamlanan', value: '$delivered', icon: Icons.task_alt, color: AppColors.success, bgColor: AppColors.successBg)
.animate(delay: 160.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
return Row(
children: [
Expanded(child: pendingCard),
const SizedBox(width: 12),
Expanded(child: inProgressCard),
const SizedBox(width: 12),
Expanded(child: sentCard),
const SizedBox(width: 12),
Expanded(child: deliveredCard),
],
);
}
return Row(
children: [
Expanded(child: pendingCard),
const SizedBox(width: 12),
Expanded(child: inProgressCard),
],
);
}
}
class _StatCard extends StatelessWidget {
const _StatCard({required this.label, required this.value, required this.icon, required this.color, required this.bgColor});
final String label;
final String value;
final IconData icon;
final Color color;
final Color bgColor;
@override
Widget build(BuildContext context) {
return 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.06), blurRadius: 12, offset: const Offset(0, 4))],
),
child: Row(
children: [
Container(
width: 44, height: 44,
decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(12)),
child: Icon(icon, color: color, size: 22),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(value, style: TextStyle(fontSize: 28, fontWeight: FontWeight.w800, color: color, height: 1)),
const SizedBox(height: 3),
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary, fontWeight: FontWeight.w500)),
],
),
],
),
);
}
}
class _AcceptAllBanner extends StatelessWidget {
const _AcceptAllBanner({required this.count, required this.loading, required this.onTap});
final int count;
final bool loading;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: AppColors.pendingBg,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: loading ? null : onTap,
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: AppColors.pending.withValues(alpha: 0.35))),
child: Row(
children: [
Container(
width: 38, height: 38,
decoration: BoxDecoration(color: AppColors.pending.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10)),
child: const Icon(Icons.notifications_active_outlined, color: AppColors.pending, size: 18),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$count yeni iş bekliyor', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
const Text('Tümünü hızlıca kabul et', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)),
],
),
),
loading
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.pending))
: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(color: AppColors.pending, borderRadius: BorderRadius.circular(8)),
child: const Text('Kabul Et', style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600)),
),
],
),
),
),
);
}
}
class _JobCard extends StatelessWidget {
const _JobCard({required this.job});
final Job job;
@override
Widget build(BuildContext context) {
final due = job.dueDate;
final isOverdue = due != null && due.isBefore(DateTime.now());
final dueText = due != null ? '${due.day.toString().padLeft(2, '0')}.${due.month.toString().padLeft(2, '0')}.${due.year}' : null;
return Semantics(
label: job.patientCode,
button: true,
excludeSemantics: true,
child: Material(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: () => context.push('/lab/jobs/${job.id}'),
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: AppColors.border)),
child: Row(
children: [
Container(
width: 46, height: 46,
decoration: BoxDecoration(color: AppColors.inProgressBg, borderRadius: BorderRadius.circular(12)),
child: Center(child: Text('${job.memberCount}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: AppColors.inProgress))),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(job.patientCode, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
const SizedBox(height: 2),
Text(job.clinicName ?? 'Klinik', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
const SizedBox(height: 6),
Wrap(
spacing: 6,
children: [
_Tag(label: job.prostheticType.label, color: AppColors.inProgress, bg: AppColors.inProgressBg),
if (job.currentStep != null) _Tag(label: job.currentStep!.label, color: AppColors.success, bg: AppColors.successBg),
],
),
],
),
),
if (dueText != null) ...[
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Icon(Icons.calendar_today_outlined, size: 13, color: isOverdue ? AppColors.cancelled : AppColors.textMuted),
const SizedBox(height: 3),
Text(dueText, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: isOverdue ? AppColors.cancelled : AppColors.textSecondary)),
],
),
],
],
),
),
),
),
);
}
}
class _Tag extends StatelessWidget {
const _Tag({required this.label, required this.color, required this.bg});
final String label;
final Color color;
final Color bg;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)),
);
}
}
class _EmptySection extends StatelessWidget {
const _EmptySection({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
Icon(Icons.check_circle_outline_rounded, color: AppColors.textSecondary.withValues(alpha: 0.5), size: 20),
const SizedBox(width: 10),
Text(message, style: TextStyle(fontSize: 14, color: AppColors.textSecondary)),
],
),
);
}
}
class _ErrorBody extends StatelessWidget {
const _ErrorBody({required this.onRetry});
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
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),
const Text('Bağlantı hatası', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
const SizedBox(height: 12),
FilledButton.icon(onPressed: onRetry, icon: const Icon(Icons.refresh_rounded, size: 18), label: const Text('Tekrar Dene')),
],
),
),
);
}
}
class _DashboardSkeleton extends StatelessWidget {
const _DashboardSkeleton({required this.companyName, required this.hPad});
final String companyName;
final double hPad;
@override
Widget build(BuildContext context) {
return CustomScrollView(
physics: const NeverScrollableScrollPhysics(),
slivers: [
_DashboardHeader(companyName: companyName),
SliverPadding(
padding: EdgeInsets.fromLTRB(hPad, 16, hPad, 0),
sliver: const SliverToBoxAdapter(
child: Row(children: [
Expanded(child: _ShimmerBox(height: 84, radius: 16)),
SizedBox(width: 12),
Expanded(child: _ShimmerBox(height: 84, radius: 16)),
]),
),
),
SliverPadding(
padding: EdgeInsets.fromLTRB(hPad, 8, hPad, 0),
sliver: SliverList.builder(
itemCount: 4,
itemBuilder: (_, i) => const Padding(padding: EdgeInsets.only(bottom: 10), child: _ShimmerBox(height: 92, radius: 14)),
),
),
],
);
}
}
class _ShimmerBox extends StatefulWidget {
const _ShimmerBox({required this.height, required this.radius});
final double height;
final double radius;
@override
State<_ShimmerBox> createState() => _ShimmerBoxState();
}
class _ShimmerBoxState extends State<_ShimmerBox> with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
late Animation<double> _anim;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1100))..repeat(reverse: true);
_anim = CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut);
}
@override
void dispose() { _ctrl.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _anim,
builder: (_, __) => Container(
height: widget.height,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(widget.radius), color: Color.lerp(const Color(0xFFE2E8F0), const Color(0xFFF1F5F9), _anim.value)),
),
);
}
}
// ── Monthly Report ──────────────────────────────────────────────────────────
class _MonthlyReportSection extends StatelessWidget {
const _MonthlyReportSection({required this.data});
final _DashboardData data;
@override
Widget build(BuildContext context) {
final pct = data.changePercent;
final isUp = pct >= 0;
final pctStr = '${isUp ? '+' : ''}${pct.toStringAsFixed(0)}%';
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.bar_chart_rounded, size: 18, color: AppColors.accent),
const SizedBox(width: 6),
Text('Aylık Rapor', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _MonthStat(
label: 'Bu Ay',
value: data.thisMonthDelivered,
highlighted: true,
),
),
const SizedBox(width: 12),
Expanded(
child: _MonthStat(
label: 'Geçen Ay',
value: data.lastMonthDelivered,
highlighted: false,
),
),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: isUp ? AppColors.successBg : AppColors.cancelledBg,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isUp ? Icons.trending_up_rounded : Icons.trending_down_rounded,
size: 16,
color: isUp ? AppColors.success : AppColors.cancelled,
),
const SizedBox(width: 4),
Text(
pctStr,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: isUp ? AppColors.success : AppColors.cancelled,
),
),
],
),
),
],
),
],
),
);
}
}
class _MonthStat extends StatelessWidget {
const _MonthStat({required this.label, required this.value, required this.highlighted});
final String label;
final int value;
final bool highlighted;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: highlighted ? AppColors.accent.withValues(alpha: 0.06) : AppColors.background,
borderRadius: BorderRadius.circular(8),
border: highlighted ? Border.all(color: AppColors.accent.withValues(alpha: 0.2)) : null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontSize: 11, color: AppColors.textSecondary, fontWeight: FontWeight.w500)),
const SizedBox(height: 2),
Text(
'$value',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: highlighted ? AppColors.accent : AppColors.textPrimary,
),
),
],
),
);
}
}
// ── Gamification Row ─────────────────────────────────────────────────────────
const _monthlyGoal = 50;
class _GamificationRow extends StatelessWidget {
const _GamificationRow({required this.data});
final _DashboardData data;
@override
Widget build(BuildContext context) {
final progress = (data.thisMonthDelivered / _monthlyGoal).clamp(0.0, 1.0);
final remaining = (_monthlyGoal - data.thisMonthDelivered).clamp(0, _monthlyGoal);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text('🏆', style: TextStyle(fontSize: 16)),
const SizedBox(width: 6),
Text('Aylık Hedef', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${data.points} puan',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.primary),
),
),
],
),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: progress,
minHeight: 8,
backgroundColor: AppColors.background,
valueColor: AlwaysStoppedAnimation<Color>(
progress >= 1.0 ? AppColors.success : AppColors.accent,
),
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${data.thisMonthDelivered} / $_monthlyGoal iş teslim edildi',
style: TextStyle(fontSize: 12, color: AppColors.textSecondary),
),
Text(
progress >= 1.0 ? 'Hedef tamamlandı!' : '$remaining iş kaldı',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: progress >= 1.0 ? AppColors.success : AppColors.textSecondary,
),
),
],
),
],
),
);
}
}
// ── Data Model ───────────────────────────────────────────────────────────────
class _DashboardData {
final List<Job> pendingJobs;
final List<Job> inProgressJobs;
final List<Job> atLabJobs;
final List<Job> atClinicJobs;
final int sentCount;
final int deliveredCount;
final int thisMonthDelivered;
final int lastMonthDelivered;
const _DashboardData({
required this.pendingJobs,
required this.inProgressJobs,
required this.atLabJobs,
required this.atClinicJobs,
required this.sentCount,
required this.deliveredCount,
required this.thisMonthDelivered,
required this.lastMonthDelivered,
});
int get points => thisMonthDelivered * 10;
double get changePercent => lastMonthDelivered == 0
? (thisMonthDelivered > 0 ? 100 : 0)
: (thisMonthDelivered - lastMonthDelivered) / lastMonthDelivered * 100;
}
@@ -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);
}
},
);
}
}
@@ -0,0 +1,42 @@
import 'package:pocketbase/pocketbase.dart';
import '../../../core/api/pocketbase_client.dart';
import '../../../models/finance_entry.dart';
class LabFinanceRepository {
LabFinanceRepository._();
static final instance = LabFinanceRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<List<FinanceEntry>> listEntries(
String tenantId, {
String? status,
int page = 1,
int limit = 30,
}) async {
final filterParts = ['tenant_id = "$tenantId"', 'type = "receivable"'];
if (status != null) filterParts.add('status = "$status"');
final result = await _pb.collection('finance_entries').getList(
page: page,
perPage: limit,
filter: filterParts.join(' && '),
expand: 'job_id',
);
return (result.items.map((r) => FinanceEntry.fromJson(r.toJson())).toList()
..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? '')));
}
Future<Map<String, double>> summary(String tenantId) async {
final all = await listEntries(tenantId, limit: 200);
double pending = 0, paid = 0;
for (final e in all) {
if (e.status == FinanceStatus.pending) {
pending += e.amount;
} else {
paid += e.amount;
}
}
return {'pending': pending, 'paid': paid};
}
}
@@ -0,0 +1,467 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../core/providers/locale_provider.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/currency_formatter.dart';
import '../../../core/widgets/gradient_app_bar.dart';
import '../../../core/widgets/pill_tabs.dart';
import '../../../models/finance_entry.dart';
import 'lab_finance_repository.dart';
enum _FinanceSort { newestFirst, byAmountDesc, byAmountAsc }
class LabFinanceScreen extends ConsumerStatefulWidget {
const LabFinanceScreen({super.key});
@override
ConsumerState<LabFinanceScreen> createState() => _LabFinanceScreenState();
}
class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
late Future<_FinanceData> _future;
_FinanceSort _sort = _FinanceSort.newestFirst;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_tabController.addListener(() {
if (mounted) setState(() {});
});
_load();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _load() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() {
_future = Future.wait([
LabFinanceRepository.instance.listEntries(tenantId, status: 'pending'),
LabFinanceRepository.instance.listEntries(tenantId, status: 'paid'),
LabFinanceRepository.instance.summary(tenantId),
]).then((results) => _FinanceData(
pending: results[0] as List<FinanceEntry>,
paid: results[1] as List<FinanceEntry>,
summary: results[2] as Map<String, double>,
));
});
}
Future<void> _showSortOptions() async {
final s = ref.read(stringsProvider);
final result = await showSortSheet(
context,
title: s.sort,
options: [s.sortNewest, s.sortAmountDesc, s.sortAmountAsc],
current: _sort.index,
);
if (result != null) {
setState(() => _sort = _FinanceSort.values[result]);
}
}
List<FinanceEntry> _sorted(List<FinanceEntry> entries) {
final list = List<FinanceEntry>.from(entries);
switch (_sort) {
case _FinanceSort.newestFirst:
list.sort((a, b) {
final da = a.dateCreated != null ? DateTime.tryParse(a.dateCreated!) : null;
final db = b.dateCreated != null ? DateTime.tryParse(b.dateCreated!) : null;
if (da == null && db == null) return 0;
if (da == null) return 1;
if (db == null) return -1;
return db.compareTo(da);
});
case _FinanceSort.byAmountDesc:
list.sort((a, b) => b.amount.compareTo(a.amount));
case _FinanceSort.byAmountAsc:
list.sort((a, b) => a.amount.compareTo(b.amount));
}
return list;
}
String _formatDate(String? raw) {
if (raw == null) return '';
try {
final dt = DateTime.parse(raw);
return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}';
} catch (_) {
return '';
}
}
@override
Widget build(BuildContext context) {
final isSortActive = _sort != _FinanceSort.newestFirst;
final s = ref.watch(stringsProvider);
final currencyCode =
ref.watch(authProvider).activeTenant?.tenant.defaultCurrency ?? 'TRY';
String formatAmount(double amount) =>
CurrencyFormatter.format(amount, currencyCode);
return Scaffold(
backgroundColor: AppColors.background,
appBar: GradientAppBar(
title: s.finance,
category: s.laboratoryCategory,
actions: [
IconButton(
onPressed: _showSortOptions,
tooltip: 'Sırala',
icon: Badge(
isLabelVisible: isSortActive,
smallSize: 8,
backgroundColor: AppColors.accent,
child: const Icon(Icons.sort_rounded),
),
),
],
),
body: RefreshIndicator(
color: AppColors.accent,
onRefresh: () async => _load(),
child: FutureBuilder<_FinanceData>(
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,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Tekrar Dene'),
),
],
),
);
}
final data = snap.data!;
final pendingTotal = data.summary['pending'] ?? 0.0;
final paidTotal = data.summary['paid'] ?? 0.0;
final pending = _sorted(data.pending);
final paid = _sorted(data.paid);
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: _SummaryCard(
label: s.pendingReceivable,
amount: formatAmount(pendingTotal),
color: AppColors.pending,
bgColor: AppColors.pendingBg,
icon: Icons.hourglass_empty_rounded,
),
),
const SizedBox(width: 12),
Expanded(
child: _SummaryCard(
label: s.collected,
amount: formatAmount(paidTotal),
color: AppColors.success,
bgColor: AppColors.successBg,
icon: Icons.check_circle_outline,
),
),
],
),
),
PillTabs(
tabs: [s.pending, s.collected],
selected: _tabController.index,
onSelect: (i) => _tabController.animateTo(i),
counts: [pending.length, paid.length],
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_EntriesList(
entries: pending,
emptyMessage: s.noPendingEntries,
emptyIcon: Icons.hourglass_empty_rounded,
formatDate: _formatDate,
formatAmount: formatAmount,
),
_EntriesList(
entries: paid,
emptyMessage: s.noPaidEntries,
emptyIcon: Icons.check_circle_outline,
formatDate: _formatDate,
formatAmount: formatAmount,
),
],
),
),
],
);
},
),
),
);
}
}
class _FinanceData {
const _FinanceData({
required this.pending,
required this.paid,
required this.summary,
});
final List<FinanceEntry> pending;
final List<FinanceEntry> paid;
final Map<String, double> summary;
}
class _SummaryCard extends StatelessWidget {
const _SummaryCard({
required this.label,
required this.amount,
required this.color,
required this.bgColor,
required this.icon,
});
final String label;
final String amount;
final Color color;
final Color bgColor;
final IconData icon;
@override
Widget build(BuildContext context) {
return 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.06),
blurRadius: 12,
offset: const Offset(0, 4))
],
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: bgColor, borderRadius: BorderRadius.circular(12)),
child: Icon(icon, color: color, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
amount,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: color,
height: 1),
),
const SizedBox(height: 3),
Text(label,
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
fontWeight: FontWeight.w500)),
],
),
),
],
),
);
}
}
class _EntriesList extends StatelessWidget {
const _EntriesList({
required this.entries,
required this.emptyMessage,
required this.emptyIcon,
required this.formatDate,
required this.formatAmount,
});
final List<FinanceEntry> entries;
final String emptyMessage;
final IconData emptyIcon;
final String Function(String?) formatDate;
final String Function(double) formatAmount;
@override
Widget build(BuildContext context) {
if (entries.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: AppColors.inProgressBg,
borderRadius: BorderRadius.circular(20)),
child: Icon(emptyIcon, size: 32, color: AppColors.inProgress),
),
const SizedBox(height: 16),
Text(emptyMessage,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary)),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
itemCount: entries.length,
itemBuilder: (ctx, i) {
final entry = entries[i];
final isPending = entry.status == FinanceStatus.pending;
final statusColor = isPending ? AppColors.pending : AppColors.success;
final statusBg = isPending ? AppColors.pendingBg : AppColors.successBg;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.surface,
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: 44,
height: 44,
decoration: BoxDecoration(
color: statusBg,
borderRadius: BorderRadius.circular(12)),
child: Icon(
isPending
? Icons.hourglass_empty_rounded
: Icons.check_circle_outline,
color: statusColor,
size: 22,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.counterpartyName ?? 'Klinik',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
if (entry.patientCode != null) ...[
const SizedBox(height: 2),
Text(
'Protokol: ${entry.patientCode}',
style: const TextStyle(
fontSize: 12, color: AppColors.textSecondary),
),
],
if (entry.dateCreated != null) ...[
const SizedBox(height: 2),
Text(
formatDate(entry.dateCreated),
style: const TextStyle(
fontSize: 12, color: AppColors.textMuted),
),
],
],
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
formatAmount(entry.amount),
style: TextStyle(
fontWeight: FontWeight.w700,
color: statusColor,
fontSize: 15,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: statusBg,
borderRadius: BorderRadius.circular(8),
),
child: Text(
entry.status.label,
style: TextStyle(
color: statusColor,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
],
),
],
),
),
);
},
);
}
}
@@ -0,0 +1,896 @@
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/services/realtime_service.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/gradient_app_bar.dart';
import '../../../core/widgets/pill_tabs.dart';
import '../../../models/job.dart';
import 'lab_jobs_repository.dart';
enum _JobSort { newestFirst, oldestFirst, byDueDate, byType }
const _kSortLabels = [
'Yeniden Eskiye',
'Eskiden Yeniye',
'Vade Tarihine Göre',
'Türe Göre',
];
class LabAllJobsScreen extends ConsumerStatefulWidget {
const LabAllJobsScreen({super.key});
@override
ConsumerState<LabAllJobsScreen> createState() => _LabAllJobsScreenState();
}
class _LabAllJobsScreenState extends ConsumerState<LabAllJobsScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final _searchController = TextEditingController();
String _searchQuery = '';
_JobSort _sort = _JobSort.newestFirst;
bool _bulkAccepting = false;
final Map<String, int?> _counts = {
'all': null,
'pending': null,
'in_progress': null,
'sent': null,
'delivered': null,
};
final _pendingTabKey = GlobalKey<_PendingJobsTabState>();
// null entry = Tümü (bütün statüsler)
static const List<String?> _statuses = [null, 'pending', 'in_progress', 'sent', 'delivered'];
static const _tabLabels = ['Tümü', 'Onay Bekleyen', 'Devam Eden', 'Gönderildi', 'Teslim Edildi'];
String _countKey(String? s) => s ?? 'all';
@override
void initState() {
super.initState();
final isDelivery = ref.read(authProvider).activeTenant?.isDeliveryOnly ?? false;
_tabController = TabController(length: 5, vsync: this, initialIndex: isDelivery ? 3 : 0);
_tabController.addListener(() {
if (mounted) setState(() {});
});
_fetchAllCounts();
}
Future<void> _fetchAllCounts() async {
final tenantId = ref.read(authProvider).activeTenant?.tenant.id;
if (tenantId == null) return;
final results = await Future.wait(
_statuses.map((s) => LabJobsRepository.instance.countByStatus(tenantId, s)),
);
if (!mounted) return;
setState(() {
for (var i = 0; i < _statuses.length; i++) {
_counts[_countKey(_statuses[i])] = results[i];
}
});
}
@override
void dispose() {
_tabController.dispose();
_searchController.dispose();
super.dispose();
}
void _onSearchChanged(String value) {
setState(() => _searchQuery = value);
}
Future<void> _showSortOptions() async {
final result = await showSortSheet(
context,
title: 'Sıralama',
options: _kSortLabels,
current: _sort.index,
);
if (result != null) {
setState(() => _sort = _JobSort.values[result]);
}
}
Future<void> _bulkAccept() async {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() => _bulkAccepting = true);
try {
await LabJobsRepository.instance.bulkAcceptPending(tenantId);
_pendingTabKey.currentState?._load();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Tüm işler kabul edildi')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
} finally {
if (mounted) setState(() => _bulkAccepting = false);
}
}
@override
Widget build(BuildContext context) {
final isSortActive = _sort != _JobSort.newestFirst;
final onPendingTab = _tabController.index == 1;
final pendingCount = _counts['pending'];
return Scaffold(
backgroundColor: AppColors.background,
appBar: GradientAppBar(
title: 'İşler',
category: 'LABORATUVAR',
searchController: _searchController,
onSearchChanged: _onSearchChanged,
searchHint: 'Protokol, klinik veya tür ara...',
actions: [
if (!onPendingTab)
IconButton(
onPressed: _showSortOptions,
tooltip: 'Sırala',
icon: Badge(
isLabelVisible: isSortActive,
smallSize: 8,
backgroundColor: AppColors.accent,
child: const Icon(Icons.sort_rounded),
),
),
],
),
floatingActionButton: onPendingTab && (pendingCount == null || pendingCount > 0)
? FloatingActionButton.extended(
onPressed: _bulkAccepting ? null : _bulkAccept,
icon: _bulkAccepting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.done_all),
label: Text(_bulkAccepting ? 'Kabul ediliyor...' : 'Tümünü Kabul Et'),
backgroundColor: AppColors.pending,
foregroundColor: Colors.white,
)
: null,
body: Column(
children: [
PillTabs(
tabs: _tabLabels,
selected: _tabController.index,
onSelect: (i) => _tabController.animateTo(i),
counts: _statuses.map((s) => _counts[_countKey(s)]).toList(),
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_LabJobsTab(
status: null,
searchQuery: _searchQuery,
sort: _sort,
onCountLoaded: (c) => setState(() => _counts['all'] = c),
),
_PendingJobsTab(
key: _pendingTabKey,
searchQuery: _searchQuery,
onCountLoaded: (c) => setState(() => _counts['pending'] = c),
),
_LabJobsTab(
status: 'in_progress',
searchQuery: _searchQuery,
sort: _sort,
onCountLoaded: (c) => setState(() => _counts['in_progress'] = c),
),
_LabJobsTab(
status: 'sent',
searchQuery: _searchQuery,
sort: _sort,
onCountLoaded: (c) => setState(() => _counts['sent'] = c),
),
_LabJobsTab(
status: 'delivered',
searchQuery: _searchQuery,
sort: _sort,
onCountLoaded: (c) => setState(() => _counts['delivered'] = c),
),
],
),
),
],
),
);
}
}
// ── Pending (Onay Bekleyen) tab ───────────────────────────────────────────────
class _PendingJobsTab extends ConsumerStatefulWidget {
const _PendingJobsTab({super.key, required this.searchQuery, this.onCountLoaded});
final String searchQuery;
final void Function(int)? onCountLoaded;
@override
ConsumerState<_PendingJobsTab> createState() => _PendingJobsTabState();
}
class _PendingJobsTabState extends ConsumerState<_PendingJobsTab> {
late Future<List<Job>> _future;
late UnsubFn _unsub;
@override
void initState() {
super.initState();
_load();
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
_unsub = RealtimeService.instance.watch(
'jobs',
filter: 'lab_tenant_id="$tenantId" && status="pending"',
onEvent: (_) { if (mounted) _load(); },
);
}
@override
void dispose() {
_unsub();
super.dispose();
}
void _load() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() {
_future = LabJobsRepository.instance.listInbound(tenantId, status: 'pending', limit: 50);
});
}
Future<void> _acceptJob(Job job) async {
try {
await LabJobsRepository.instance.acceptJob(job);
_load();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('İş kabul edildi')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
}
List<Job> _filtered(List<Job> jobs) {
final q = widget.searchQuery.toLowerCase().trim();
if (q.isEmpty) return jobs;
return jobs.where((j) =>
j.patientCode.toLowerCase().contains(q) ||
(j.clinicName?.toLowerCase().contains(q) ?? false) ||
j.prostheticType.label.toLowerCase().contains(q)
).toList();
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
color: AppColors.accent,
onRefresh: () async => _load(),
child: FutureBuilder<List<Job>>(
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,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Tekrar Dene'),
),
],
),
);
}
final all = snap.data!;
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onCountLoaded?.call(all.length);
});
final jobs = _filtered(all);
if (jobs.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: AppColors.successBg,
borderRadius: BorderRadius.circular(20),
),
child: const Icon(Icons.inbox_outlined, color: AppColors.success, size: 32),
),
const SizedBox(height: 16),
Text(
widget.searchQuery.isNotEmpty ? 'Sonuç bulunamadı' : 'Onay bekleyen iş yok',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
),
if (widget.searchQuery.isEmpty) ...[
const SizedBox(height: 6),
const Text('Tüm işler kabul edildi', style: TextStyle(color: AppColors.textSecondary, fontSize: 13)),
],
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
itemCount: jobs.length,
itemBuilder: (ctx, i) {
final job = jobs[i];
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _PendingJobCard(
job: job,
onAccept: () => _acceptJob(job),
),
);
},
);
},
),
);
}
}
class _PendingJobCard extends StatefulWidget {
const _PendingJobCard({required this.job, required this.onAccept});
final Job job;
final VoidCallback onAccept;
@override
State<_PendingJobCard> createState() => _PendingJobCardState();
}
class _PendingJobCardState extends State<_PendingJobCard> {
bool _accepting = false;
@override
Widget build(BuildContext context) {
final job = widget.job;
return Dismissible(
key: ValueKey(job.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
decoration: BoxDecoration(
color: AppColors.success,
borderRadius: BorderRadius.circular(14),
),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check_rounded, color: Colors.white, size: 28),
SizedBox(height: 4),
Text('Kabul Et', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600)),
],
),
),
confirmDismiss: (_) async {
setState(() => _accepting = true);
try {
await LabJobsRepository.instance.acceptJob(job);
return true;
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
return false;
} finally {
if (mounted) setState(() => _accepting = false);
}
},
child: Material(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: () => context.push('/lab/jobs/${job.id}'),
borderRadius: BorderRadius.circular(14),
child: Container(
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: IntrinsicHeight(
child: Row(
children: [
Container(
width: 4,
decoration: const BoxDecoration(
color: AppColors.pending,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(14),
bottomLeft: Radius.circular(14),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
job.patientCode,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.textPrimary),
),
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.local_hospital_outlined, size: 12, color: AppColors.textMuted),
const SizedBox(width: 4),
Expanded(
child: Text(
job.clinicName ?? 'Klinik',
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 6),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: AppColors.pendingBg,
borderRadius: BorderRadius.circular(6),
),
child: Text(
job.prostheticType.label,
style: const TextStyle(fontSize: 11, color: AppColors.pending, fontWeight: FontWeight.w600),
),
),
if (job.dueDate != null) ...[
const SizedBox(width: 6),
const Icon(Icons.calendar_today_outlined, size: 11, color: AppColors.textMuted),
const SizedBox(width: 3),
Text(
'${job.dueDate!.day.toString().padLeft(2, '0')}.${job.dueDate!.month.toString().padLeft(2, '0')}.${job.dueDate!.year}',
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
),
],
],
),
],
),
),
const SizedBox(width: 8),
_accepting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.success),
)
: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
decoration: BoxDecoration(
color: AppColors.successBg,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.success.withValues(alpha: 0.3)),
),
child: const Text(
'Kabul Et',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.success),
),
),
],
),
),
),
],
),
),
),
),
),
);
}
}
class _LabJobsTab extends ConsumerStatefulWidget {
const _LabJobsTab({
required this.status,
required this.searchQuery,
required this.sort,
this.onCountLoaded,
});
final String? status; // null = tüm statüsler
final String searchQuery;
final _JobSort sort;
final void Function(int)? onCountLoaded;
@override
ConsumerState<_LabJobsTab> createState() => _LabJobsTabState();
}
class _LabJobsTabState extends ConsumerState<_LabJobsTab> {
late Future<List<Job>> _future;
late UnsubFn _unsub;
@override
void initState() {
super.initState();
_load();
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
_unsub = RealtimeService.instance.watch(
'jobs',
filter: 'lab_tenant_id="$tenantId"',
onEvent: (_) { if (mounted) _load(); },
);
}
@override
void dispose() {
_unsub();
super.dispose();
}
void _load() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() {
_future = LabJobsRepository.instance
.listInbound(tenantId, status: widget.status, limit: 50);
});
}
List<Job> _applyFilters(List<Job> jobs) {
var list = jobs;
final q = widget.searchQuery.toLowerCase().trim();
if (q.isNotEmpty) {
list = list.where((j) {
return j.patientCode.toLowerCase().contains(q) ||
(j.clinicName?.toLowerCase().contains(q) ?? false) ||
j.prostheticType.label.toLowerCase().contains(q) ||
(j.currentStep?.label.toLowerCase().contains(q) ?? false);
}).toList();
}
final sorted = List<Job>.from(list);
switch (widget.sort) {
case _JobSort.newestFirst:
sorted.sort((a, b) => b.dateCreated.compareTo(a.dateCreated));
case _JobSort.oldestFirst:
sorted.sort((a, b) => a.dateCreated.compareTo(b.dateCreated));
case _JobSort.byDueDate:
sorted.sort((a, b) {
if (a.dueDate == null && b.dueDate == null) return 0;
if (a.dueDate == null) return 1;
if (b.dueDate == null) return -1;
return a.dueDate!.compareTo(b.dueDate!);
});
case _JobSort.byType:
sorted.sort(
(a, b) => a.prostheticType.label.compareTo(b.prostheticType.label));
}
return sorted;
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
color: AppColors.accent,
onRefresh: () async => _load(),
child: FutureBuilder<List<Job>>(
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,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Tekrar Dene'),
),
],
),
);
}
final all = snap.data!;
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onCountLoaded?.call(all.length);
});
final jobs = _applyFilters(all);
if (jobs.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.work_off_outlined,
color: AppColors.inProgress, size: 32),
),
const SizedBox(height: 16),
Text(
widget.searchQuery.isNotEmpty
? 'Sonuç bulunamadı'
: 'Henüz iş yok',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
itemCount: jobs.length,
itemBuilder: (ctx, i) {
final job = jobs[i];
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _LabJobCard(
job: job,
onTap: () => context.push('/lab/jobs/${job.id}'),
),
);
},
);
},
),
);
}
}
class _LabJobCard extends StatelessWidget {
const _LabJobCard({required this.job, required this.onTap});
final Job job;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final isOverdue =
job.dueDate != null && job.dueDate!.isBefore(DateTime.now());
final accentColor = _statusColor(job.status);
return Semantics(
label: job.patientCode,
button: true,
excludeSemantics: true,
child: Material(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Container(
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: IntrinsicHeight(
child: Row(
children: [
Container(
width: 4,
decoration: BoxDecoration(
color: accentColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(14),
bottomLeft: Radius.circular(14),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
job.patientCode,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
),
if (job.currentStep != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppColors.inProgressBg,
borderRadius: BorderRadius.circular(8),
),
child: Text(
job.currentStep!.label,
style: const TextStyle(
color: AppColors.inProgress,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 5),
Row(
children: [
const Icon(Icons.local_hospital_outlined,
size: 12, color: AppColors.textMuted),
const SizedBox(width: 4),
Expanded(
child: Text(
job.clinicName ?? 'Klinik',
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 5),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(6),
),
child: Text(
job.prostheticType.label,
style: const TextStyle(
fontSize: 11,
color: AppColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
),
if (job.dueDate != null) ...[
const SizedBox(width: 8),
Icon(Icons.calendar_today_outlined,
size: 11,
color: isOverdue
? AppColors.cancelled
: AppColors.textMuted),
const SizedBox(width: 3),
Text(
_fmt(job.dueDate!),
style: TextStyle(
fontSize: 11,
color: isOverdue
? AppColors.cancelled
: AppColors.textMuted,
fontWeight: isOverdue
? FontWeight.w600
: FontWeight.normal,
),
),
],
],
),
],
),
),
),
const Padding(
padding: EdgeInsets.only(right: 10),
child: Icon(Icons.chevron_right,
color: AppColors.textMuted, size: 20),
),
],
),
),
),
),
),
);
}
String _fmt(DateTime d) =>
'${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
Color _statusColor(JobStatus status) {
switch (status) {
case JobStatus.pending:
return AppColors.pending;
case JobStatus.inProgress:
return AppColors.inProgress;
case JobStatus.sent:
return AppColors.accent;
case JobStatus.delivered:
return AppColors.success;
case JobStatus.cancelled:
return AppColors.cancelled;
}
}
}
@@ -0,0 +1,764 @@
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/services/realtime_service.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/job.dart';
import '../../../models/job_file.dart';
import '../../../features/shared/job_files_repository.dart';
import '../../../features/shared/job_files_panel.dart';
import '../../../core/services/job_history_service.dart';
import 'lab_jobs_repository.dart';
// ── Adaptive sheet helper ────────────────────────────────────────────────────
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 LabJobDetailScreen extends ConsumerStatefulWidget {
const LabJobDetailScreen({super.key, required this.jobId});
final String jobId;
@override
ConsumerState<LabJobDetailScreen> createState() => _LabJobDetailScreenState();
}
class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
Job? _job;
bool _loadingJob = false;
String? _loadError;
bool _isActing = false;
late Future<List<JobFile>> _filesFuture;
late UnsubFn _unsub;
@override
void initState() {
super.initState();
_load();
_loadFiles();
_unsub = RealtimeService.instance.watch(
'jobs',
topic: widget.jobId,
onEvent: (_) { if (mounted && !_isActing) _load(); },
);
}
@override
void dispose() {
_unsub();
super.dispose();
}
Future<void> _load() async {
setState(() { _loadingJob = true; _loadError = null; });
try {
final job = await LabJobsRepository.instance.getJob(widget.jobId);
if (mounted) setState(() { _job = job; _loadingJob = false; });
} catch (e) {
if (mounted) setState(() { _loadError = e.toString(); _loadingJob = false; });
}
}
void _loadFiles() {
setState(() {
_filesFuture = JobFilesRepository.instance.listForJob(widget.jobId);
});
}
Future<void> _cancelJob(Job job) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('İşi İptal Et'),
content: const Text('Bu iş geri alınamaz şekilde iptal edilir. Onaylıyor musunuz?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Vazgeç')),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('İptal Et'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _isActing = true);
try {
final updated = await LabJobsRepository.instance.cancelJob(job.id, job);
if (mounted) {
setState(() { _job = _job!.copyWith(status: updated.status); _isActing = false; });
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('İş iptal edildi.')));
}
} catch (e) {
if (mounted) {
setState(() => _isActing = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e')));
}
}
}
Future<void> _acceptJob(Job job) async {
setState(() => _isActing = true);
try {
final updated = await LabJobsRepository.instance.acceptJob(job);
if (mounted) {
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('İş kabul edildi')),
);
}
} catch (e) {
if (mounted) {
setState(() => _isActing = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e')));
}
}
}
void _showHandToClinicSheet(Job job) {
_showAdaptive(
context,
_HandToClinicSheet(
job: job,
onDone: (Job updated) {
if (mounted) setState(() => _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName));
},
),
);
}
Color _statusColor(JobStatus status) {
return switch (status) {
JobStatus.pending => AppColors.pending,
JobStatus.inProgress => AppColors.inProgress,
JobStatus.sent => AppColors.accent,
JobStatus.delivered => AppColors.success,
JobStatus.cancelled => AppColors.cancelled,
};
}
Color _statusBg(JobStatus status) {
return switch (status) {
JobStatus.pending => AppColors.pendingBg,
JobStatus.inProgress => AppColors.inProgressBg,
JobStatus.sent => AppColors.inProgressBg,
JobStatus.delivered => AppColors.successBg,
JobStatus.cancelled => AppColors.cancelledBg,
};
}
String _formatDate(DateTime dt, {bool withTime = false}) {
final d = '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}';
if (!withTime || (dt.hour == 0 && dt.minute == 0)) return d;
return '$d ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('İş Detayı'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
),
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_loadingJob && _job == null) {
return const Center(
child: CircularProgressIndicator(color: AppColors.accent));
}
if (_loadError != null && _job == null) {
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: $_loadError',
style: const TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Tekrar Dene'),
),
],
),
);
}
if (_job == null) return const SizedBox.shrink();
{
final job = _job!;
final membership = ref.read(authProvider).activeTenant;
final isDeliveryOnly = membership?.isDeliveryOnly ?? false;
final canCancelJobs = membership?.canCancelJobs ?? true;
final canSendToClinic = !isDeliveryOnly &&
job.status == JobStatus.inProgress &&
job.location == JobLocation.atLab;
final canAccept = !isDeliveryOnly && job.status == JobStatus.pending;
return ListView(
padding: const EdgeInsets.all(16),
children: [
// Header card
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: [
Row(
children: [
Expanded(
child: Text(
job.patientCode,
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.bold,
color: AppColors.textPrimary),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 5),
decoration: BoxDecoration(
color: _statusBg(job.status),
borderRadius: BorderRadius.circular(10),
),
child: Text(
job.status.label,
style: TextStyle(
color: _statusColor(job.status),
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
),
],
),
const SizedBox(height: 12),
_InfoRow(
icon: Icons.business,
label: 'Klinik',
value: job.clinicName ?? '-'),
_InfoRow(
icon: Icons.medical_services_outlined,
label: 'Protez Tipi',
value: job.prostheticType.label),
_InfoRow(
icon: Icons.format_list_numbered,
label: 'Üye Sayısı',
value: '${job.memberCount} üye'),
if (job.color != null)
_InfoRow(
icon: Icons.color_lens_outlined,
label: 'Renk',
value: job.color!),
if (job.dueDate != null)
_InfoRow(
icon: Icons.calendar_today,
label: 'Teslim Tarihi',
value: _formatDate(job.dueDate!, withTime: true),
valueColor: job.dueDate!.isBefore(DateTime.now())
? AppColors.cancelled
: null),
_InfoRow(
icon: Icons.add_circle_outline,
label: 'Oluşturulma',
value: _formatDate(job.dateCreated)),
if (job.price != null && job.currency != null)
_InfoRow(
icon: Icons.attach_money,
label: 'Fiyat',
value:
'${job.price!.toStringAsFixed(2)} ${job.currency}'),
if (job.description != null &&
job.description!.isNotEmpty)
_InfoRow(
icon: Icons.notes,
label: 'Açıklama',
value: job.description!),
],
),
),
const SizedBox(height: 16),
// Stepper
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: [
Row(
children: [
Text(
'İş Adımları',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: job.provaRequired
? AppColors.inProgressBg
: AppColors.successBg,
borderRadius: BorderRadius.circular(6),
),
child: Text(
job.provaRequired ? 'Provalı' : 'Provasız',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: job.provaRequired
? AppColors.inProgress
: AppColors.success,
),
),
),
],
),
const SizedBox(height: 16),
_JobStepper(
steps: job.stepTemplate,
currentStep: job.currentStep,
historyFuture: JobHistoryService.instance
.listForJob(job.id),
),
],
),
),
const SizedBox(height: 24),
// Action buttons
if (_isActing)
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Center(child: CircularProgressIndicator(color: AppColors.accent)),
)
else ...[
if (canAccept)
FilledButton.icon(
onPressed: () => _acceptJob(job),
icon: const Icon(Icons.check_circle_outline),
label: const Text('Kabul Et'),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
backgroundColor: AppColors.success,
),
),
if (canSendToClinic)
FilledButton.icon(
onPressed: () => _showHandToClinicSheet(job),
icon: const Icon(Icons.send_outlined),
label: Text(
(job.isLastStep)
? 'Son Prova - Teslime Gönder'
: 'Prova için Kliniğe Gönder',
),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
backgroundColor: (job.isLastStep)
? AppColors.success
: AppColors.inProgress,
),
),
if (canCancelJobs && job.status == JobStatus.pending) ...[
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => _cancelJob(job),
icon: const Icon(Icons.close_rounded),
label: const Text('İşi İptal Et'),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(50),
foregroundColor: AppColors.cancelled,
side: const BorderSide(color: AppColors.cancelled),
),
),
],
],
const SizedBox(height: 20),
JobFilesPanel(
job: job,
filesFuture: _filesFuture,
onRefresh: _loadFiles,
),
const SizedBox(height: 16),
],
);
}
}
}
// ── Hand to Clinic Sheet ─────────────────────────────────────────────────────
class _HandToClinicSheet extends StatefulWidget {
const _HandToClinicSheet({required this.job, required this.onDone});
final Job job;
final void Function(Job updatedJob) onDone;
@override
State<_HandToClinicSheet> createState() => _HandToClinicSheetState();
}
class _HandToClinicSheetState extends State<_HandToClinicSheet> {
final _noteController = TextEditingController();
bool _sending = false;
@override
void dispose() {
_noteController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
final isLast = widget.job.isLastStep;
final stepLabel = widget.job.currentStep?.label ?? '';
final buttonLabel = isLast
? (widget.job.provaRequired ? 'Son Prova · Teslime Gönder' : 'Teslime Gönder')
: '$stepLabel için Kliniğe Gönder';
final buttonColor = isLast ? AppColors.success : AppColors.inProgress;
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.vertical(
top: isDesktop ? Radius.zero : const Radius.circular(20),
),
),
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 24,
bottom: isDesktop
? 24
: MediaQuery.of(context).viewInsets.bottom + 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
buttonLabel,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold,
color: AppColors.textPrimary),
),
const SizedBox(height: 8),
Text(
isLast
? 'İş teslim edilecek olarak işaretlenecek.'
: 'İş klinikteki prova için gönderilecek.',
style: const TextStyle(color: AppColors.textSecondary),
),
const SizedBox(height: 16),
TextField(
controller: _noteController,
decoration: const InputDecoration(
labelText: 'Not (isteğe bağlı)',
hintText: 'Klinik için not ekleyin...',
),
maxLines: 3,
),
const SizedBox(height: 16),
FilledButton(
onPressed: _sending
? null
: () async {
setState(() => _sending = true);
final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
try {
final updated = await LabJobsRepository.instance.handToClinic(
widget.job.id,
widget.job,
note: _noteController.text.trim().isEmpty
? null
: _noteController.text.trim(),
);
navigator.pop();
messenger.showSnackBar(
SnackBar(
content: Text(isLast
? 'İş teslim için gönderildi'
: 'Prova için klinik\'e gönderildi')),
);
if (context.mounted) widget.onDone(updated);
} catch (e) {
if (context.mounted) {
setState(() => _sending = false);
messenger.showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
},
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
backgroundColor: buttonColor,
),
child: _sending
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: Text(buttonLabel),
),
],
),
);
}
}
// ── Info Row ─────────────────────────────────────────────────────────────────
class _InfoRow extends StatelessWidget {
const _InfoRow({
required this.icon,
required this.label,
required this.value,
this.valueColor,
});
final IconData icon;
final String label;
final String value;
final Color? valueColor;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: AppColors.textMuted),
const SizedBox(width: 10),
SizedBox(
width: 110,
child: Text(
label,
style: const TextStyle(color: AppColors.textSecondary, fontSize: 13),
),
),
Expanded(
child: Text(
value,
style: TextStyle(
fontWeight: FontWeight.w500,
color: valueColor ?? AppColors.textPrimary,
fontSize: 14,
),
),
),
],
),
);
}
}
// ── Job Stepper ───────────────────────────────────────────────────────────────
class _JobStepper extends StatelessWidget {
const _JobStepper({
required this.steps,
required this.currentStep,
required this.historyFuture,
});
final List<JobStep> steps;
final JobStep? currentStep;
final Future<List<JobHistoryEntry>> historyFuture;
@override
Widget build(BuildContext context) {
return FutureBuilder<List<JobHistoryEntry>>(
future: historyFuture,
builder: (ctx, snap) {
final history = snap.data ?? [];
// Revizyon sayısı per adım
final Map<JobStep, int> revisionCounts = {};
for (final e in history) {
if (e.action == JobHistoryAction.revisionRequested && e.step != null) {
revisionCounts[e.step!] = (revisionCounts[e.step!] ?? 0) + 1;
}
}
final currentIndex =
currentStep != null ? steps.indexOf(currentStep!) : -1;
return Column(
children: List.generate(steps.length, (i) {
final step = steps[i];
final isCompleted = i < currentIndex;
final isCurrent = i == currentIndex;
final isLastItem = i == steps.length - 1;
final revCount = revisionCounts[step] ?? 0;
Color dotColor;
IconData dotIcon;
if (isCompleted) {
dotColor = AppColors.success;
dotIcon = Icons.check_circle;
} else if (isCurrent) {
dotColor = AppColors.inProgress;
dotIcon = Icons.radio_button_checked;
} else {
dotColor = AppColors.muted;
dotIcon = Icons.radio_button_unchecked;
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
Icon(dotIcon, color: dotColor, size: 24),
if (!isLastItem)
Container(
width: 2,
height: 44,
color: i < currentIndex
? AppColors.success.withValues(alpha: 0.35)
: AppColors.border,
),
],
),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 2, bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
step.label,
style: TextStyle(
fontWeight: isCurrent
? FontWeight.bold
: FontWeight.normal,
color: isCompleted
? AppColors.success
: isCurrent
? AppColors.inProgress
: AppColors.textMuted,
fontSize: 15,
),
),
if (revCount > 0) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: AppColors.cancelledBg,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'$revCount revizyon',
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: AppColors.cancelled,
),
),
),
],
],
),
if (isCurrent)
Text(
step.description,
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
),
],
);
}),
);
},
);
}
}
@@ -0,0 +1,335 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../core/services/realtime_service.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/gradient_app_bar.dart';
import '../../../models/job.dart';
import 'lab_jobs_repository.dart';
class LabJobsInboundScreen extends ConsumerStatefulWidget {
const LabJobsInboundScreen({super.key});
@override
ConsumerState<LabJobsInboundScreen> createState() =>
_LabJobsInboundScreenState();
}
class _LabJobsInboundScreenState extends ConsumerState<LabJobsInboundScreen> {
late Future<List<Job>> _future;
bool _acceptingAll = false;
late UnsubFn _unsub;
@override
void initState() {
super.initState();
_load();
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
_unsub = RealtimeService.instance.watch(
'jobs',
filter: "lab_tenant_id='$tenantId'",
onEvent: (_) { if (mounted) _load(); },
);
}
@override
void dispose() {
_unsub();
super.dispose();
}
void _load() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() {
_future =
LabJobsRepository.instance.listInbound(tenantId, status: 'pending');
});
}
Future<void> _acceptJob(Job job) async {
try {
await LabJobsRepository.instance.acceptJob(job);
_load();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('İş kabul edildi')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
}
Future<void> _bulkAccept() async {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() => _acceptingAll = true);
try {
await LabJobsRepository.instance.bulkAcceptPending(tenantId);
_load();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Tüm işler kabul edildi')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
} finally {
if (mounted) setState(() => _acceptingAll = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: GradientAppBar(
title: 'Gelen İşler',
category: 'LABORATUVAR',
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _acceptingAll ? null : _bulkAccept,
icon: _acceptingAll
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.done_all),
label:
Text(_acceptingAll ? 'Kabul ediliyor...' : 'Tümünü Kabul Et'),
backgroundColor: AppColors.pending,
foregroundColor: Colors.white,
),
body: RefreshIndicator(
onRefresh: () async => _load(),
child: FutureBuilder<List<Job>>(
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'),
),
],
),
);
}
final jobs = snap.data!;
if (jobs.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.inbox_outlined, size: 64, color: AppColors.textMuted),
const SizedBox(height: 16),
Text(
'Bekleyen iş yok',
style: TextStyle(
fontSize: 16, color: AppColors.textSecondary),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 80),
itemCount: jobs.length,
itemBuilder: (ctx, i) {
final job = jobs[i];
return _InboundJobCard(
job: job,
onAccept: () => _acceptJob(job),
);
},
);
},
),
),
);
}
}
class _InboundJobCard extends StatefulWidget {
const _InboundJobCard({required this.job, required this.onAccept});
final Job job;
final VoidCallback onAccept;
@override
State<_InboundJobCard> createState() => _InboundJobCardState();
}
class _InboundJobCardState extends State<_InboundJobCard> {
bool _accepting = false;
String _formatDate(DateTime dt) =>
'${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}';
@override
Widget build(BuildContext context) {
final job = widget.job;
return Semantics(
label: job.patientCode,
button: true,
excludeSemantics: true,
child: Dismissible(
key: ValueKey(job.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: AppColors.success,
borderRadius: BorderRadius.circular(12),
),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check, color: Colors.white, size: 28),
SizedBox(height: 4),
Text('Kabul Et',
style: TextStyle(color: Colors.white, fontSize: 12)),
],
),
),
confirmDismiss: (_) async {
setState(() => _accepting = true);
try {
await LabJobsRepository.instance.acceptJob(job);
return true;
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
return false;
} finally {
if (mounted) setState(() => _accepting = false);
}
},
child: Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
CircleAvatar(
backgroundColor: AppColors.pendingBg,
child: const Icon(Icons.assignment_outlined,
color: AppColors.pending),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
job.patientCode,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 2),
Text(
job.clinicName ?? 'Klinik',
style: TextStyle(
color: AppColors.textSecondary, fontSize: 13),
),
const SizedBox(height: 4),
Row(
children: [
_Chip(
label: job.prostheticType.label,
color: AppColors.inProgressBg,
textColor: AppColors.inProgress,
),
const SizedBox(width: 6),
_Chip(
label: '${job.memberCount} üye',
color: AppColors.background,
textColor: AppColors.textSecondary,
),
],
),
const SizedBox(height: 4),
Text(
_formatDate(job.dateCreated),
style: TextStyle(
color: AppColors.textMuted, fontSize: 12),
),
],
),
),
const SizedBox(width: 8),
_accepting
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: FilledButton(
onPressed: widget.onAccept,
style: FilledButton.styleFrom(
backgroundColor: AppColors.success,
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('Kabul Et',
style: TextStyle(fontSize: 13)),
),
],
),
),
),
),
);
}
}
class _Chip extends StatelessWidget {
const _Chip(
{required this.label,
required this.color,
required this.textColor});
final String label;
final Color color;
final Color textColor;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(10),
),
child: Text(label,
style: TextStyle(
color: textColor,
fontSize: 11,
fontWeight: FontWeight.w500)),
);
}
}
@@ -0,0 +1,131 @@
import 'dart:async';
import 'package:pocketbase/pocketbase.dart';
import '../../../core/api/pocketbase_client.dart';
import '../../../core/services/job_history_service.dart';
import '../../../models/job.dart';
const _listExpand = 'clinic_tenant_id,lab_tenant_id';
const _detailExpand = 'clinic_tenant_id,lab_tenant_id,patient_id';
class LabJobsRepository {
LabJobsRepository._();
static final instance = LabJobsRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<List<Job>> listInbound(
String labTenantId, {
String? status,
int page = 1,
int limit = 30,
}) async {
final filterParts = ['lab_tenant_id = "$labTenantId"'];
if (status != null) filterParts.add('status = "$status"');
final result = await _pb.collection('jobs').getList(
page: page,
perPage: limit,
filter: filterParts.join(' && '),
expand: _listExpand,
);
return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated)));
}
Future<List<Job>> listInProgress(String labTenantId, {int limit = 50, String? location}) async {
final filterParts = ['lab_tenant_id = "$labTenantId"', 'status = "in_progress"'];
if (location != null) filterParts.add('location = "$location"');
final result = await _pb.collection('jobs').getList(
perPage: limit,
filter: filterParts.join(' && '),
expand: _listExpand,
);
return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
..sort((a, b) {
if (a.dueDate == null && b.dueDate == null) return b.dateCreated.compareTo(a.dateCreated);
if (a.dueDate == null) return 1;
if (b.dueDate == null) return -1;
final cmp = a.dueDate!.compareTo(b.dueDate!);
return cmp != 0 ? cmp : b.dateCreated.compareTo(a.dateCreated);
}));
}
Future<Job> getJob(String jobId) async {
final record = await _pb.collection('jobs').getOne(jobId, expand: _detailExpand);
return Job.fromJson(record.toJson());
}
Future<Job> acceptJob(Job pendingJob) async {
final firstStep = pendingJob.stepTemplate.first;
final record = await _pb.collection('jobs').update(pendingJob.id, body: {
'status': 'in_progress',
'current_step': firstStep.value,
'location': 'at_lab',
});
final job = Job.fromJson(record.toJson());
unawaited(JobHistoryService.instance.append(
jobId: pendingJob.id,
clinicTenantId: job.clinicTenantId,
labTenantId: job.labTenantId,
action: JobHistoryAction.accepted,
step: firstStep,
));
return job;
}
Future<Job> handToClinic(String jobId, Job job, {String? note}) async {
final isFinal = job.currentStep == JobStep.cilaBitim;
final patch = isFinal
? {'status': 'sent', 'location': 'at_clinic'}
: {'location': 'at_clinic'};
final record = await _pb.collection('jobs').update(jobId, body: patch);
final updated = Job.fromJson(record.toJson());
unawaited(JobHistoryService.instance.append(
jobId: jobId,
clinicTenantId: job.clinicTenantId,
labTenantId: job.labTenantId,
action: JobHistoryAction.handedToClinic,
step: job.currentStep,
note: note,
));
return updated;
}
Future<Job> cancelJob(String jobId, Job job) async {
final record = await _pb.collection('jobs').update(jobId, body: {
'status': 'cancelled',
});
unawaited(JobHistoryService.instance.append(
jobId: jobId,
clinicTenantId: job.clinicTenantId,
labTenantId: job.labTenantId,
action: JobHistoryAction.cancelled,
step: job.currentStep,
));
return Job.fromJson(record.toJson());
}
Future<void> bulkAcceptPending(String labTenantId) async {
final pending = await listInbound(labTenantId, status: 'pending', limit: 200);
await Future.wait(pending.map((j) => acceptJob(j)));
}
Future<int> countByStatus(String labTenantId, String? status) async {
final filter = status != null
? 'lab_tenant_id = "$labTenantId" && status = "$status"'
: 'lab_tenant_id = "$labTenantId"';
final r = await _pb.collection('jobs').getList(perPage: 1, filter: filter);
return r.totalItems;
}
Future<int> countDelivered(String labTenantId, {DateTime? from, DateTime? to}) async {
final parts = ['lab_tenant_id = "$labTenantId"', 'status = "delivered"'];
if (from != null) parts.add('updated >= "${_date(from)}"');
if (to != null) parts.add('updated < "${_date(to)}"');
final r = await _pb.collection('jobs').getList(perPage: 1, filter: parts.join(' && '));
return r.totalItems;
}
static String _date(DateTime d) => d.toIso8601String().split('T').first;
}
@@ -0,0 +1,39 @@
import 'package:pocketbase/pocketbase.dart';
import '../../../core/api/pocketbase_client.dart';
import '../../../models/prosthetic_product.dart';
class LabProductsRepository {
LabProductsRepository._();
static final instance = LabProductsRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<List<ProstheticProduct>> listProducts(
String labTenantId, {
bool? isActive,
}) async {
final filterParts = ['lab_tenant_id = "$labTenantId"'];
if (isActive != null) filterParts.add('is_active = $isActive');
final result = await _pb.collection('prosthetic_products').getList(
filter: filterParts.join(' && '),
perPage: 200,
);
return (result.items.map((r) => ProstheticProduct.fromJson(r.toJson())).toList()
..sort((a, b) => a.name.compareTo(b.name)));
}
Future<ProstheticProduct> createProduct(ProstheticProduct product) async {
final record = await _pb.collection('prosthetic_products').create(body: product.toJson());
return ProstheticProduct.fromJson(record.toJson());
}
Future<ProstheticProduct> updateProduct(String id, Map<String, dynamic> patch) async {
final record = await _pb.collection('prosthetic_products').update(id, body: patch);
return ProstheticProduct.fromJson(record.toJson());
}
Future<void> deleteProduct(String id) async {
await _pb.collection('prosthetic_products').delete(id);
}
}
@@ -0,0 +1,620 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/gradient_app_bar.dart';
import '../../../models/prosthetic_product.dart';
import 'lab_products_repository.dart';
const _prostheticTypes = [
('metal_porselen', 'Metal Porselen'),
('zirkonyum', 'Zirkonyum'),
('implant_ustu_zirkonyum', 'İmplant Üstü Zirkonyum'),
('gecici', 'Geçici'),
('e_max', 'E-Max'),
('diger', 'Diğer'),
];
String _typeLabel(String value) {
for (final t in _prostheticTypes) {
if (t.$1 == value) return t.$2;
}
return value;
}
// ── Adaptive sheet helper ────────────────────────────────────────────────────
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 LabProductsScreen extends ConsumerStatefulWidget {
const LabProductsScreen({super.key});
@override
ConsumerState<LabProductsScreen> createState() => _LabProductsScreenState();
}
class _LabProductsScreenState extends ConsumerState<LabProductsScreen> {
late Future<List<ProstheticProduct>> _future;
final _searchController = TextEditingController();
String _searchQuery = '';
@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 = LabProductsRepository.instance.listProducts(tenantId);
});
}
Future<void> _toggleActive(ProstheticProduct product) async {
try {
await LabProductsRepository.instance
.updateProduct(product.id, {'is_active': !product.isActive});
_load();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
}
Future<void> _deleteProduct(ProstheticProduct product) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Ürünü Sil'),
content: Text(
'"${product.name}" ürününü silmek istediğinize emin misiniz?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('İptal'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(
backgroundColor: AppColors.cancelled),
child: const Text('Sil'),
),
],
),
);
if (confirmed != true) return;
try {
await LabProductsRepository.instance.deleteProduct(product.id);
_load();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ürün silindi')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
}
void _showProductSheet({ProstheticProduct? existing}) {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
_showAdaptive(
context,
_ProductForm(
labTenantId: tenantId,
existing: existing,
onSaved: () {
_load();
},
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: GradientAppBar(
title: 'Ürün Kataloğu',
category: 'LABORATUVAR',
searchController: _searchController,
onSearchChanged: (v) => setState(() => _searchQuery = v),
searchHint: 'Ürün adı veya türü ara...',
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showProductSheet(),
backgroundColor: AppColors.accent,
foregroundColor: Colors.white,
icon: const Icon(Icons.add),
label: const Text('Yeni Ürün'),
),
body: RefreshIndicator(
color: AppColors.accent,
onRefresh: () async => _load(),
child: FutureBuilder<List<ProstheticProduct>>(
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,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Tekrar Dene')),
],
),
);
}
final allProducts = snap.data!;
final q = _searchQuery.toLowerCase().trim();
final products = q.isEmpty
? allProducts
: allProducts.where((p) =>
p.name.toLowerCase().contains(q) ||
_typeLabel(p.prostheticType).toLowerCase().contains(q)).toList();
if (allProducts.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.inventory_2_outlined,
size: 32, color: AppColors.inProgress),
),
const SizedBox(height: 16),
Text(
q.isNotEmpty ? 'Sonuç bulunamadı' : 'Henüz ürün eklenmedi',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
const SizedBox(height: 12),
if (q.isEmpty) FilledButton.icon(
onPressed: () => _showProductSheet(),
icon: const Icon(Icons.add),
label: const Text('İlk Ürünü Ekle'),
),
],
),
);
}
if (products.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.search_off_rounded,
size: 32, color: AppColors.inProgress),
),
const SizedBox(height: 16),
const Text(
'Sonuç bulunamadı',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 80),
itemCount: products.length,
itemBuilder: (ctx, i) {
final product = products[i];
final statusColor =
product.isActive ? AppColors.inProgress : AppColors.textMuted;
final statusBg =
product.isActive ? AppColors.inProgressBg : AppColors.surfaceVariant;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: GestureDetector(
onLongPress: () => _deleteProduct(product),
child: Material(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: () => _showProductSheet(existing: product),
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: 44,
height: 44,
decoration: BoxDecoration(
color: statusBg,
borderRadius: BorderRadius.circular(12)),
child: Icon(Icons.medical_services_outlined,
color: statusColor, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: product.isActive
? AppColors.textPrimary
: AppColors.textMuted),
),
const SizedBox(height: 2),
Text(
_typeLabel(product.prostheticType),
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary),
),
if (product.unitPrice != null) ...[
const SizedBox(height: 2),
Text(
'${product.unitPrice!.toStringAsFixed(2)} ${product.currency ?? 'TRY'}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.success),
),
],
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Switch(
value: product.isActive,
onChanged: (_) => _toggleActive(product),
activeTrackColor: AppColors.accent,
),
IconButton(
icon: const Icon(Icons.edit_outlined,
color: AppColors.textSecondary,
size: 20),
onPressed: () =>
_showProductSheet(existing: product),
),
],
),
],
),
),
),
),
),
);
},
);
},
),
),
);
}
}
// ── Product Form ─────────────────────────────────────────────────────────────
class _ProductForm extends StatefulWidget {
const _ProductForm({
required this.labTenantId,
required this.onSaved,
this.existing,
});
final String labTenantId;
final ProstheticProduct? existing;
final VoidCallback onSaved;
@override
State<_ProductForm> createState() => _ProductFormState();
}
class _ProductFormState extends State<_ProductForm> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameCtrl;
late final TextEditingController _priceCtrl;
late final TextEditingController _descCtrl;
late String _selectedType;
late String _currency;
late bool _isActive;
bool _saving = false;
@override
void initState() {
super.initState();
final p = widget.existing;
_nameCtrl = TextEditingController(text: p?.name ?? '');
_priceCtrl = TextEditingController(
text: p?.unitPrice != null ? p!.unitPrice!.toString() : '');
_descCtrl = TextEditingController(text: p?.description ?? '');
_selectedType = p?.prostheticType ?? _prostheticTypes.first.$1;
_currency = p?.currency ?? 'TRY';
_isActive = p?.isActive ?? true;
}
@override
void dispose() {
_nameCtrl.dispose();
_priceCtrl.dispose();
_descCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
final price = double.tryParse(_priceCtrl.text.trim());
final product = ProstheticProduct(
id: widget.existing?.id ?? '',
labTenantId: widget.labTenantId,
name: _nameCtrl.text.trim(),
prostheticType: _selectedType,
unitPrice: price,
currency: _currency,
isActive: _isActive,
description:
_descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim(),
);
try {
if (widget.existing != null) {
await LabProductsRepository.instance.updateProduct(
widget.existing!.id,
product.toJson(),
);
} else {
await LabProductsRepository.instance.createProduct(product);
}
widget.onSaved();
// Pop using form's own context to ensure correct Navigator inside dialog/sheet
if (mounted) Navigator.of(context).pop();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
final isEdit = widget.existing != null;
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.vertical(
top: isDesktop ? Radius.zero : const Radius.circular(20),
),
),
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 24,
bottom: isDesktop
? 24
: MediaQuery.of(context).viewInsets.bottom + 24,
),
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Text(
isEdit ? 'Ürünü Düzenle' : 'Yeni Ürün',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.bold,
color: AppColors.textPrimary),
),
),
IconButton(
icon: const Icon(Icons.close,
color: AppColors.textSecondary),
onPressed: () => Navigator.of(context).pop(),
),
],
),
const SizedBox(height: 16),
// Name
TextFormField(
controller: _nameCtrl,
decoration:
const InputDecoration(labelText: 'Ürün Adı *'),
validator: (v) =>
v == null || v.trim().isEmpty ? 'Ürün adı gerekli' : null,
),
const SizedBox(height: 12),
// Prosthetic type dropdown
DropdownButtonFormField<String>(
initialValue: _selectedType,
decoration:
const InputDecoration(labelText: 'Protez Tipi *'),
items: _prostheticTypes
.map((t) => DropdownMenuItem(
value: t.$1,
child: Text(t.$2),
))
.toList(),
onChanged: (v) => setState(() => _selectedType = v!),
),
const SizedBox(height: 12),
// Price + currency row
Row(
children: [
Expanded(
flex: 3,
child: TextFormField(
controller: _priceCtrl,
decoration:
const InputDecoration(labelText: 'Birim Fiyat'),
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
validator: (v) {
if (v != null && v.isNotEmpty) {
if (double.tryParse(v) == null) {
return 'Geçerli fiyat girin';
}
}
return null;
},
),
),
const SizedBox(width: 8),
Expanded(
flex: 2,
child: DropdownButtonFormField<String>(
initialValue: _currency,
decoration:
const InputDecoration(labelText: 'Para Birimi'),
items: ['TRY', 'USD', 'EUR']
.map((c) => DropdownMenuItem(
value: c,
child: Text(c),
))
.toList(),
onChanged: (v) => setState(() => _currency = v!),
),
),
],
),
const SizedBox(height: 12),
// Description
TextFormField(
controller: _descCtrl,
decoration: const InputDecoration(
labelText: 'Açıklama (isteğe bağlı)'),
maxLines: 2,
),
const SizedBox(height: 12),
// Active toggle
SwitchListTile(
title: const Text('Aktif',
style: TextStyle(color: AppColors.textPrimary)),
value: _isActive,
onChanged: (v) => setState(() => _isActive = v),
contentPadding: EdgeInsets.zero,
activeTrackColor: AppColors.accent,
),
const SizedBox(height: 8),
FilledButton(
onPressed: _saving ? null : _save,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48)),
child: _saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: Text(isEdit ? 'Kaydet' : 'Ekle'),
),
],
),
),
),
);
}
}
@@ -0,0 +1,790 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/l10n/app_strings.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../core/providers/locale_provider.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/tenant.dart';
import '../../shared/tenant_team_screen.dart';
import '../connections/lab_connections_screen.dart';
class LabSettingsScreen extends ConsumerWidget {
const LabSettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authProvider);
final s = ref.watch(stringsProvider);
final profile = auth.profile;
final membership = auth.activeTenant;
final tenant = membership?.tenant;
final canEdit = membership?.isAdmin ?? false;
return Scaffold(
appBar: AppBar(title: Text(s.settings)),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// User card
_SectionHeader(title: s.userInfo),
_UserCard(profile: profile),
const SizedBox(height: 20),
// Lab info
_SectionHeader(
title: s.labInfo,
action: canEdit
? IconButton(
icon: const Icon(Icons.edit_outlined,
size: 18, color: AppColors.accent),
tooltip: s.edit,
onPressed: () => _showEditSheet(context, ref, tenant, s),
)
: null,
),
_InfoCard(children: [
_InfoTile(
icon: Icons.science_outlined,
label: s.labName,
value: tenant?.companyName ?? '-',
),
_InfoTile(
icon: Icons.payments_outlined,
label: s.currency,
value: tenant?.defaultCurrency ?? 'TRY',
),
_InfoTileBadge(
icon: Icons.circle_outlined,
label: s.status,
value: tenant?.status == 'active' ? s.active : (tenant?.status ?? '-'),
badgeColor: AppColors.success,
badgeBg: AppColors.successBg,
),
_InfoTile(
icon: Icons.star_outline,
label: s.role,
value: _roleLabel(membership?.role, s),
),
]),
const SizedBox(height: 20),
// Connections
if (membership?.showConnections ?? false) ...[
_SectionHeader(title: s.connections),
_InfoCard(children: [
_NavTile(
icon: Icons.link_rounded,
iconColor: AppColors.inProgress,
iconBg: AppColors.inProgressBg,
title: s.clinicConnections,
subtitle: s.clinicConnectionsSub,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const LabConnectionsScreen()),
),
),
]),
const SizedBox(height: 20),
],
// Other memberships
if (auth.memberships.length > 1) ...[
_SectionHeader(title: s.otherMemberships),
_InfoCard(children: [
for (final m
in auth.memberships.where((m) => m.id != membership?.id))
_NavTile(
icon: Icons.switch_account_outlined,
iconColor: AppColors.inProgress,
iconBg: AppColors.inProgressBg,
title: m.tenant.companyName,
subtitle: _tenantKindLabel(m.tenant.kind, s),
onTap: () {
ref.read(authProvider.notifier).setActiveTenant(m);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(s.tenantSelected(m.tenant.companyName))),
);
},
),
]),
const SizedBox(height: 20),
],
// Team management + Reports
if (membership?.canManageUsers ?? false) ...[
_SectionHeader(title: s.management),
_InfoCard(children: [
_NavTile(
icon: Icons.group_outlined,
iconColor: AppColors.inProgress,
iconBg: AppColors.inProgressBg,
title: s.team,
subtitle: s.teamSub,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const TenantTeamScreen()),
),
),
_NavTile(
icon: Icons.discount_outlined,
iconColor: AppColors.success,
iconBg: AppColors.successBg,
title: s.discounts,
subtitle: s.discountsSub,
onTap: () => context.push(routeLabDiscounts),
),
_NavTile(
icon: Icons.bar_chart_rounded,
iconColor: AppColors.accent,
iconBg: AppColors.inProgressBg,
title: s.reports,
subtitle: s.reportsSub,
onTap: () => context.push(routeLabReports),
),
_NavTile(
icon: Icons.auto_awesome_outlined,
iconColor: const Color(0xFF7C3AED),
iconBg: const Color(0xFFF3E8FF),
title: s.aiAssistant,
subtitle: s.aiAssistantSub,
onTap: () => context.push(routeLabAi),
),
]),
const SizedBox(height: 20),
],
// Preferences (language)
_SectionHeader(title: s.preferences),
_InfoCard(children: [
_NavTile(
icon: Icons.language_outlined,
iconColor: AppColors.accent,
iconBg: AppColors.inProgressBg,
title: s.appLanguage,
subtitle: _currentLanguageLabel(ref.watch(localeProvider).languageCode, s),
onTap: () => _showLanguagePicker(context, ref, s),
),
]),
const SizedBox(height: 20),
// Sign out
_SignOutCard(ref: ref, s: s),
const SizedBox(height: 32),
const Center(
child: Text('DLS — Dental Lab System',
style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
),
const SizedBox(height: 4),
const Center(
child: Text('Geliştirici: kovakyazilim.com',
style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
),
const SizedBox(height: 8),
],
),
);
}
void _showEditSheet(BuildContext context, WidgetRef ref, Tenant? tenant, AppStrings s) {
if (tenant == null) return;
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _EditTenantSheet(
tenant: tenant,
s: s,
onSave: (name, currency) async {
await ref.read(authProvider.notifier).updateTenantInfo(
tenantId: tenant.id,
companyName: name,
defaultCurrency: currency,
);
},
),
);
}
void _showLanguagePicker(BuildContext context, WidgetRef ref, AppStrings s) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (_) => _LanguagePickerSheet(s: s, ref: ref),
);
}
static String _tenantKindLabel(TenantKind? kind, AppStrings s) =>
switch (kind) {
TenantKind.clinic => s.tenantKindClinic,
TenantKind.lab => s.tenantKindLab,
null => '-',
};
static String _currentLanguageLabel(String code, AppStrings s) => switch (code) {
'en' => s.languageEnglish,
'ru' => s.languageRussian,
'ar' => s.languageArabic,
'de' => s.languageGerman,
_ => s.languageTurkish,
};
static String _roleLabel(TenantRole? role, AppStrings s) => switch (role) {
TenantRole.owner => s.roleOwner,
TenantRole.admin => s.roleAdmin,
TenantRole.technician => s.roleTechnician,
TenantRole.delivery => s.roleDelivery,
TenantRole.finance => s.roleFinance,
TenantRole.doctor => s.roleDoctor,
TenantRole.member => s.roleMember,
null => '-',
};
}
// ── Language picker sheet ─────────────────────────────────────────────────────
class _LanguagePickerSheet extends ConsumerWidget {
const _LanguagePickerSheet({required this.s, required this.ref});
final AppStrings s;
final WidgetRef ref;
@override
Widget build(BuildContext context, WidgetRef _) {
final currentLocale = ref.watch(localeProvider);
final options = [
('tr', '🇹🇷', s.languageTurkish),
('en', '🇬🇧', s.languageEnglish),
('ru', '🇷🇺', s.languageRussian),
('ar', '🇸🇦', s.languageArabic),
('de', '🇩🇪', s.languageGerman),
];
return Container(
decoration: const BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppColors.border,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
Text(
s.languageSelection,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 12),
for (final (code, flag, label) in options)
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
leading: Text(flag, style: const TextStyle(fontSize: 24)),
title: Text(
label,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
),
trailing: currentLocale.languageCode == code
? const Icon(Icons.check_circle_rounded,
color: AppColors.accent)
: null,
onTap: () {
ref.read(localeProvider.notifier).setLocale(Locale(code));
ref.read(authProvider.notifier).updateLanguage(code);
Navigator.pop(context);
},
),
SizedBox(height: MediaQuery.paddingOf(context).bottom + 4),
],
),
);
}
}
// ── Edit sheet ────────────────────────────────────────────────────────────────
class _EditTenantSheet extends StatefulWidget {
const _EditTenantSheet({
required this.tenant,
required this.s,
required this.onSave,
});
final Tenant tenant;
final AppStrings s;
final Future<void> Function(String companyName, String currency) onSave;
@override
State<_EditTenantSheet> createState() => _EditTenantSheetState();
}
class _EditTenantSheetState extends State<_EditTenantSheet> {
late final TextEditingController _nameController;
late String _selectedCurrency;
bool _saving = false;
static const _currencies = [
('TRY', '', 'Türk Lirası'),
('USD', '\$', 'US Dollar'),
('EUR', '', 'Euro'),
('GBP', '£', 'British Pound'),
('AED', 'د.إ', 'UAE Dirham'),
];
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.tenant.companyName);
_selectedCurrency = widget.tenant.defaultCurrency;
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
Future<void> _submit() async {
final name = _nameController.text.trim();
if (name.isEmpty) return;
setState(() => _saving = true);
final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
try {
await widget.onSave(name, _selectedCurrency);
navigator.pop();
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('${widget.s.errorPrefix}: $e')));
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final s = widget.s;
return Padding(
padding:
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Container(
decoration: const BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppColors.border,
borderRadius: BorderRadius.circular(2)),
),
),
const SizedBox(height: 16),
Text(s.editLabInfo,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary)),
const SizedBox(height: 16),
TextFormField(
controller: _nameController,
decoration: InputDecoration(
labelText: s.labName,
hintText: s.labNameHint,
),
textCapitalization: TextCapitalization.words,
),
const SizedBox(height: 14),
Text(s.currency,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary)),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: _selectedCurrency,
decoration: InputDecoration(
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.border)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.border)),
),
items: [
for (final (code, symbol, name) in _currencies)
DropdownMenuItem(
value: code,
child: Text('$symbol $name ($code)',
style: const TextStyle(fontSize: 14)),
),
],
onChanged: (v) {
if (v != null) setState(() => _selectedCurrency = v);
},
),
const SizedBox(height: 20),
if (_saving)
const Center(
child: CircularProgressIndicator(color: AppColors.accent))
else
FilledButton(
onPressed: _submit,
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 48)),
child: Text(s.save),
),
SizedBox(height: MediaQuery.paddingOf(context).bottom + 4),
],
),
),
);
}
}
// ── Reusable UI pieces ────────────────────────────────────────────────────────
class _UserCard extends StatelessWidget {
const _UserCard({required this.profile});
final dynamic profile;
@override
Widget build(BuildContext context) {
final displayName = (profile?.displayName?.isNotEmpty == true)
? profile!.displayName as String
: 'Kullanıcı';
final initial = (profile?.displayName?.isNotEmpty == true
? (profile!.displayName as String)[0]
: (profile?.email as String?)?[0] ?? '?')
.toUpperCase();
return 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: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: AppColors.inProgressBg,
borderRadius: BorderRadius.circular(14)),
child: Center(
child: Text(initial,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.inProgress)),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(displayName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary)),
const SizedBox(height: 2),
Text(profile?.email as String? ?? '',
style: const TextStyle(
fontSize: 13, color: AppColors.textSecondary)),
],
),
),
],
),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.title, this.action});
final String title;
final Widget? action;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.accent,
letterSpacing: 0.3),
),
),
if (action != null) action!,
],
),
);
}
}
class _InfoCard extends StatelessWidget {
const _InfoCard({required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) {
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: [
for (int i = 0; i < children.length; i++) ...[
children[i],
if (i < children.length - 1)
const Divider(
height: 1, indent: 16, endIndent: 16, color: AppColors.border),
],
],
),
);
}
}
class _InfoTile extends StatelessWidget {
const _InfoTile(
{required this.icon, required this.label, required this.value});
final IconData icon;
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(icon, size: 18, color: AppColors.textSecondary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(
fontSize: 11, color: AppColors.textMuted)),
const SizedBox(height: 2),
Text(value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary)),
],
),
),
],
),
);
}
}
class _InfoTileBadge extends StatelessWidget {
const _InfoTileBadge({
required this.icon,
required this.label,
required this.value,
required this.badgeColor,
required this.badgeBg,
});
final IconData icon;
final String label;
final String value;
final Color badgeColor;
final Color badgeBg;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(icon, size: 18, color: AppColors.textSecondary),
const SizedBox(width: 12),
Expanded(
child: Text(label,
style: const TextStyle(
fontSize: 11, color: AppColors.textMuted)),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: badgeBg,
borderRadius: BorderRadius.circular(8),
),
child: Text(value,
style: TextStyle(
color: badgeColor,
fontSize: 12,
fontWeight: FontWeight.w600)),
),
],
),
);
}
}
class _NavTile extends StatelessWidget {
const _NavTile({
required this.icon,
required this.iconColor,
required this.iconBg,
required this.title,
required this.onTap,
this.subtitle,
});
final IconData icon;
final Color iconColor;
final Color iconBg;
final String title;
final String? subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: iconBg, borderRadius: BorderRadius.circular(9)),
child: Icon(icon, color: iconColor, size: 18),
),
title: Text(title,
style: const TextStyle(
fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
subtitle: subtitle != null
? Text(subtitle!,
style: const TextStyle(color: AppColors.textSecondary))
: null,
trailing:
const Icon(Icons.chevron_right, color: AppColors.textSecondary),
onTap: onTap,
);
}
}
class _SignOutCard extends StatelessWidget {
const _SignOutCard({required this.ref, required this.s});
final WidgetRef ref;
final AppStrings s;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.cancelledBg),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 12,
offset: const Offset(0, 4))
],
),
child: ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.cancelledBg,
borderRadius: BorderRadius.circular(9)),
child: const Icon(Icons.logout,
color: AppColors.cancelled, size: 18),
),
title: Text(s.signOut,
style: const TextStyle(
color: AppColors.cancelled, fontWeight: FontWeight.w600)),
onTap: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(s.signOutTitle),
content: Text(s.signOutConfirm),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(s.cancel)),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: AppColors.cancelled),
child: Text(s.signOut),
),
],
),
);
if (confirmed == true) {
await ref.read(authProvider.notifier).signOut();
}
},
),
);
}
}
+930
View File
@@ -0,0 +1,930 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/providers/auth_provider.dart';
import '../../core/services/ai_actions.dart';
import '../../core/services/ai_context_builder.dart';
import '../../core/services/ai_service.dart';
import '../../core/theme/app_theme.dart';
import '../../core/utils/file_download_helper.dart';
import '../../models/job_file.dart';
import '../../models/tenant.dart';
class AiChatScreen extends ConsumerStatefulWidget {
const AiChatScreen({super.key});
@override
ConsumerState<AiChatScreen> createState() => _AiChatScreenState();
}
class _AiChatScreenState extends ConsumerState<AiChatScreen> {
final _inputController = TextEditingController();
final _scrollController = ScrollController();
final List<_Message> _messages = [];
String? _systemPrompt;
bool _loadingContext = true;
bool _streaming = false;
@override
void initState() {
super.initState();
_loadContext();
}
@override
void dispose() {
_inputController.dispose();
_scrollController.dispose();
super.dispose();
}
Future<void> _loadContext() async {
try {
final membership = ref.read(authProvider).activeTenant!;
final prompt = await AiContextBuilder.instance.build(membership);
if (mounted) setState(() { _systemPrompt = prompt; _loadingContext = false; });
} catch (_) {
if (mounted) setState(() => _loadingContext = false);
}
}
Future<void> _send([String? override]) async {
final text = (override ?? _inputController.text).trim();
if (text.isEmpty || _streaming || _systemPrompt == null) return;
_inputController.clear();
FocusScope.of(context).unfocus();
final apiMessages = [
..._messages.map((m) => {'role': m.isUser ? 'user' : 'assistant', 'content': m.rawText}),
{'role': 'user', 'content': text},
];
setState(() {
_messages.add(_Message.user(text));
_messages.add(_Message.assistantStreaming());
_streaming = true;
});
_scrollToBottom();
var accumulated = '';
try {
final stream = AiService.instance.streamChat(
systemPrompt: _systemPrompt!,
messages: apiMessages,
);
await for (final chunk in stream) {
if (!mounted) break;
accumulated += chunk;
setState(() => _messages[_messages.length - 1] = _Message.assistantStreaming(accumulated));
_scrollToBottom();
}
if (mounted) {
setState(() => _messages[_messages.length - 1] = _Message.assistantDone(accumulated));
}
} catch (e) {
if (mounted) {
setState(() => _messages[_messages.length - 1] = _Message.error('Bir hata olustu, tekrar deneyin.'));
}
} finally {
if (mounted) setState(() => _streaming = false);
}
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
title: Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: AppColors.accent,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.auto_awesome, size: 16, color: Colors.white),
),
const SizedBox(width: 10),
const Text('AI Asistan'),
],
),
actions: [
if (_messages.isNotEmpty)
IconButton(
onPressed: () => setState(() => _messages.clear()),
icon: const Icon(Icons.refresh_outlined, size: 20),
tooltip: 'Sohbeti temizle',
),
],
),
body: Column(
children: [
Expanded(
child: _loadingContext
? const _LoadingContext()
: _messages.isEmpty
? _WelcomeView(onSuggestion: _send)
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
itemCount: _messages.length,
itemBuilder: (_, i) => _MessageBubble(
message: _messages[i],
membership: ref.read(authProvider).activeTenant!,
),
),
),
_InputBar(
controller: _inputController,
enabled: !_loadingContext && !_streaming,
streaming: _streaming,
onSend: _send,
),
],
),
);
}
}
// ── Message model ─────────────────────────────────────────────────────────────
enum _MsgKind { user, streaming, done, error }
class _Message {
_Message._({required this.kind, required this.rawText});
factory _Message.user(String text) =>
_Message._(kind: _MsgKind.user, rawText: text);
factory _Message.assistantStreaming([String text = '']) =>
_Message._(kind: _MsgKind.streaming, rawText: text);
factory _Message.assistantDone(String text) =>
_Message._(kind: _MsgKind.done, rawText: text);
factory _Message.error(String text) =>
_Message._(kind: _MsgKind.error, rawText: text);
final _MsgKind kind;
final String rawText;
bool get isUser => kind == _MsgKind.user;
bool get isStreaming => kind == _MsgKind.streaming;
bool get isError => kind == _MsgKind.error;
}
// ── Welcome view ──────────────────────────────────────────────────────────────
class _WelcomeView extends StatelessWidget {
const _WelcomeView({required this.onSuggestion});
final void Function(String) onSuggestion;
static const _suggestions = [
'Bekleyen islerimin ozeti nedir?',
'Gecikmiş iş var mı?',
'Bu ay kac is tamamlandi?',
'Revizyon oranım ne durumda?',
'Finans durumumu ozetle.',
'Son yuklenen dosyalari goster.',
];
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 32, 20, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: AppColors.accent,
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.auto_awesome, size: 28, color: Colors.white),
),
const SizedBox(height: 16),
const Text(
'Merhaba! Size nasil yardimci olabilirim?',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: AppColors.textPrimary),
),
const SizedBox(height: 6),
const Text(
'Isler, finans ve ekip hakkinda soru sorabilir, islem yapabilirsiniz.',
style: TextStyle(fontSize: 14, color: AppColors.textSecondary),
),
const SizedBox(height: 28),
const Text(
'ONERILER',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.textMuted, letterSpacing: 0.8),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: _suggestions
.map((s) => _SuggestionChip(label: s, onTap: () => onSuggestion(s)))
.toList(),
),
],
),
);
}
}
class _SuggestionChip extends StatelessWidget {
const _SuggestionChip({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.border),
),
child: Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textPrimary)),
),
);
}
}
// ── Message bubble ────────────────────────────────────────────────────────────
class _MessageBubble extends StatelessWidget {
const _MessageBubble({required this.message, required this.membership});
final _Message message;
final TenantMembership membership;
@override
Widget build(BuildContext context) {
if (message.isUser) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppColors.accent,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(4),
),
),
child: Text(
message.rawText,
style: const TextStyle(fontSize: 14, height: 1.5, color: Colors.white),
),
),
),
],
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 28,
height: 28,
margin: const EdgeInsets.only(right: 8, top: 2),
decoration: BoxDecoration(color: AppColors.accent, borderRadius: BorderRadius.circular(8)),
child: const Icon(Icons.auto_awesome, size: 14, color: Colors.white),
),
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(16),
),
border: Border.all(color: AppColors.border),
),
child: message.isStreaming && message.rawText.isEmpty
? const _TypingDots()
: _MessageContent(message: message, membership: membership),
),
),
],
),
);
}
}
// ── Message content: markdown + action buttons ────────────────────────────────
class _MessageContent extends StatelessWidget {
const _MessageContent({required this.message, required this.membership});
final _Message message;
final TenantMembership membership;
@override
Widget build(BuildContext context) {
if (message.isError) {
return Text(
message.rawText,
style: const TextStyle(fontSize: 14, height: 1.5, color: AppColors.cancelled),
);
}
// During streaming: show raw text without parsing actions
if (message.isStreaming) {
return _MarkdownText(message.rawText, color: AppColors.textPrimary);
}
// Done: parse segments → text + action buttons
final segments = parseSegments(message.rawText);
if (segments.isEmpty) {
return _MarkdownText(message.rawText, color: AppColors.textPrimary);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: segments.map((seg) {
if (seg is TextSegment) {
return _MarkdownText(seg.text, color: AppColors.textPrimary);
}
if (seg is ActionSegment) {
return Padding(
padding: const EdgeInsets.only(top: 10),
child: _ActionCard(action: seg.action, membership: membership),
);
}
return const SizedBox.shrink();
}).toList(),
);
}
}
// ── Action card ───────────────────────────────────────────────────────────────
enum _ActionState { idle, confirming, loading, success, error, files }
class _ActionCard extends StatefulWidget {
const _ActionCard({required this.action, required this.membership});
final AiAction action;
final TenantMembership membership;
@override
State<_ActionCard> createState() => _ActionCardState();
}
class _ActionCardState extends State<_ActionCard> {
_ActionState _state = _ActionState.idle;
String _resultMsg = '';
List<JobFile> _files = [];
AiAction get action => widget.action;
Future<void> _execute() async {
setState(() => _state = _ActionState.loading);
final outcome = await AiActionExecutor.execute(action, widget.membership);
if (!mounted) return;
switch (outcome) {
case ActionSuccess(:final message):
setState(() { _state = _ActionState.success; _resultMsg = message; });
case ActionError(:final error):
setState(() { _state = _ActionState.error; _resultMsg = error; });
case ActionFiles(:final files):
setState(() { _state = _ActionState.files; _files = files; });
}
}
void _onTap() {
if (action.isDangerous) {
setState(() => _state = _ActionState.confirming);
} else {
_execute();
}
}
@override
Widget build(BuildContext context) {
return switch (_state) {
_ActionState.idle => _idleButton(),
_ActionState.confirming => _confirmCard(),
_ActionState.loading => _loadingCard(),
_ActionState.success => _resultCard(success: true),
_ActionState.error => _resultCard(success: false),
_ActionState.files => _filesCard(),
};
}
Widget _idleButton() {
final isDanger = action.isDangerous;
final isFile = action.isFileAction;
final color = isDanger
? AppColors.cancelled
: isFile
? AppColors.inProgress
: AppColors.accent;
final bgColor = isDanger
? AppColors.cancelledBg
: isFile
? AppColors.inProgressBg
: AppColors.accent.withValues(alpha: 0.1);
final icon = isDanger
? Icons.cancel_outlined
: isFile
? Icons.folder_outlined
: action.type == 'add_member'
? Icons.person_add_outlined
: Icons.check_circle_outline;
return GestureDetector(
onTap: _onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 8),
Flexible(
child: Text(
action.label,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: color),
),
),
],
),
),
);
}
Widget _confirmCard() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.cancelledBg,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.cancelled.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Icon(Icons.warning_amber_rounded, size: 16, color: AppColors.cancelled),
const SizedBox(width: 6),
const Expanded(
child: Text(
'Bu islem geri alinamayabilir.',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.cancelled),
),
),
],
),
const SizedBox(height: 4),
Text(action.label, style: const TextStyle(fontSize: 13, color: AppColors.textPrimary)),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => setState(() => _state = _ActionState.idle),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 8),
side: BorderSide(color: AppColors.border),
),
child: const Text('Vazgec', style: TextStyle(fontSize: 13)),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: _execute,
style: FilledButton.styleFrom(
backgroundColor: AppColors.cancelled,
padding: const EdgeInsets.symmetric(vertical: 8),
),
child: const Text('Onayla', style: TextStyle(fontSize: 13)),
),
),
],
),
],
),
);
}
Widget _loadingCard() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppColors.background,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.accent),
),
const SizedBox(width: 8),
Text('Isleniyor...', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
],
),
);
}
Widget _resultCard({required bool success}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: success ? AppColors.successBg : AppColors.cancelledBg,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
success ? Icons.check_circle_outline : Icons.error_outline,
size: 16,
color: success ? AppColors.success : AppColors.cancelled,
),
const SizedBox(width: 8),
Flexible(
child: Text(
_resultMsg,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: success ? AppColors.success : AppColors.cancelled,
),
),
),
],
),
);
}
Widget _filesCard() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.inProgressBg,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.inProgress.withValues(alpha: 0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Icon(Icons.folder_open_outlined, size: 15, color: AppColors.inProgress),
const SizedBox(width: 6),
Text(
'${_files.length} dosya',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.inProgress),
),
],
),
const SizedBox(height: 8),
..._files.map((f) => _FileDownloadRow(file: f)),
],
),
);
}
}
// ── File download row ─────────────────────────────────────────────────────────
class _FileDownloadRow extends StatefulWidget {
const _FileDownloadRow({required this.file});
final JobFile file;
@override
State<_FileDownloadRow> createState() => _FileDownloadRowState();
}
class _FileDownloadRowState extends State<_FileDownloadRow> {
bool _downloading = false;
IconData get _icon => switch (widget.file.kind) {
JobFileKind.scan => Icons.view_in_ar_rounded,
JobFileKind.image => Icons.image_outlined,
JobFileKind.document => Icons.description_outlined,
};
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Icon(_icon, size: 14, color: AppColors.inProgress),
const SizedBox(width: 6),
Expanded(
child: Text(
widget.file.name,
style: const TextStyle(fontSize: 12, color: AppColors.textPrimary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 4),
Text(
widget.file.sizeLabel,
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
),
const SizedBox(width: 6),
_downloading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 1.5, color: AppColors.inProgress),
)
: GestureDetector(
onTap: () async {
setState(() => _downloading = true);
await FileDownloadHelper.download(context, widget.file);
if (mounted) setState(() => _downloading = false);
},
child: const Icon(Icons.download_outlined, size: 18, color: AppColors.inProgress),
),
],
),
);
}
}
// ── Simple markdown renderer ──────────────────────────────────────────────────
class _MarkdownText extends StatelessWidget {
const _MarkdownText(this.text, {required this.color});
final String text;
final Color color;
@override
Widget build(BuildContext context) {
if (text.isEmpty) return const SizedBox.shrink();
final lines = text.split('\n');
final widgets = <Widget>[];
bool prevEmpty = false;
for (final raw in lines) {
final line = raw.trimRight();
if (line.isEmpty) {
if (!prevEmpty) widgets.add(const SizedBox(height: 6));
prevEmpty = true;
continue;
}
prevEmpty = false;
// Header ## or ###
if (line.startsWith('## ') || line.startsWith('### ')) {
final content = line.replaceFirst(RegExp(r'^#{2,3}\s+'), '');
widgets.add(Padding(
padding: const EdgeInsets.only(top: 4, bottom: 2),
child: Text(
content,
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: color),
),
));
continue;
}
// Bullet
if (line.startsWith('- ') || line.startsWith('')) {
final content = line.substring(2);
widgets.add(Padding(
padding: const EdgeInsets.only(left: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('', style: TextStyle(color: color, height: 1.5, fontSize: 14)),
Expanded(child: _inlineText(content, color)),
],
),
));
continue;
}
widgets.add(_inlineText(line, color));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: widgets,
);
}
Widget _inlineText(String text, Color baseColor) {
final spans = <InlineSpan>[];
final boldPattern = RegExp(r'\*\*(.+?)\*\*');
int last = 0;
for (final m in boldPattern.allMatches(text)) {
if (m.start > last) {
spans.add(TextSpan(text: text.substring(last, m.start)));
}
spans.add(TextSpan(
text: m.group(1),
style: const TextStyle(fontWeight: FontWeight.w700),
));
last = m.end;
}
if (last < text.length) spans.add(TextSpan(text: text.substring(last)));
return RichText(
text: TextSpan(
style: TextStyle(fontSize: 14, height: 1.5, color: baseColor),
children: spans,
),
);
}
}
// ── Typing dots ───────────────────────────────────────────────────────────────
class _TypingDots extends StatefulWidget {
const _TypingDots();
@override
State<_TypingDots> createState() => _TypingDotsState();
}
class _TypingDotsState extends State<_TypingDots> with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 900))..repeat();
}
@override
void dispose() { _ctrl.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return SizedBox(
height: 18,
child: AnimatedBuilder(
animation: _ctrl,
builder: (_, __) => Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (i) {
final t = ((_ctrl.value * 3) - i).clamp(0.0, 1.0);
final opacity = (t < 0.5 ? t * 2 : (1 - t) * 2).clamp(0.3, 1.0);
return Container(
width: 6, height: 6,
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
color: AppColors.accent.withValues(alpha: opacity),
shape: BoxShape.circle,
),
);
}),
),
),
);
}
}
// ── Input bar ─────────────────────────────────────────────────────────────────
class _InputBar extends StatelessWidget {
const _InputBar({
required this.controller,
required this.enabled,
required this.streaming,
required this.onSend,
});
final TextEditingController controller;
final bool enabled;
final bool streaming;
final VoidCallback onSend;
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.paddingOf(context).bottom;
return Container(
padding: EdgeInsets.fromLTRB(12, 8, 12, 8 + bottom),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border(top: BorderSide(color: AppColors.border)),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
enabled: enabled,
maxLines: 4,
minLines: 1,
textInputAction: TextInputAction.send,
onSubmitted: (_) => onSend(),
decoration: InputDecoration(
hintText: streaming ? 'Yanit bekleniyor...' : 'Bir sey sorun...',
hintStyle: const TextStyle(color: AppColors.textMuted),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: BorderSide(color: AppColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: const BorderSide(color: AppColors.accent, width: 1.5),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
isDense: true,
),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: enabled ? onSend : null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 40,
height: 40,
decoration: BoxDecoration(
color: enabled ? AppColors.accent : AppColors.border,
shape: BoxShape.circle,
),
child: Center(
child: streaming
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.arrow_upward_rounded, color: Colors.white, size: 18),
),
),
),
],
),
);
}
}
// ── Loading context ───────────────────────────────────────────────────────────
class _LoadingContext extends StatelessWidget {
const _LoadingContext();
@override
Widget build(BuildContext context) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: AppColors.accent, strokeWidth: 2),
SizedBox(height: 12),
Text('Veriler yukleniyor...', style: TextStyle(color: AppColors.textSecondary, fontSize: 13)),
],
),
);
}
}
+619
View File
@@ -0,0 +1,619 @@
import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import '../../core/api/pocketbase_client.dart';
import '../../core/theme/app_theme.dart';
import '../../models/job.dart';
import '../../models/job_file.dart';
import 'job_files_repository.dart';
const _maxFileSizeBytes = 50 * 1024 * 1024; // 50 MB per file
const _maxFilesPerJob = 10;
class JobFilesPanel extends StatefulWidget {
const JobFilesPanel({
super.key,
required this.job,
required this.filesFuture,
required this.onRefresh,
});
final Job job;
final Future<List<JobFile>> filesFuture;
final VoidCallback onRefresh;
@override
State<JobFilesPanel> createState() => _JobFilesPanelState();
}
class _JobFilesPanelState extends State<JobFilesPanel> {
_UploadState? _upload;
List<JobFile>? _files;
bool _loadingFiles = false;
String? _filesError;
@override
void initState() {
super.initState();
_subscribeToFuture(widget.filesFuture);
}
@override
void didUpdateWidget(JobFilesPanel oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.filesFuture != widget.filesFuture) {
_subscribeToFuture(widget.filesFuture);
}
}
void _subscribeToFuture(Future<List<JobFile>> future) {
setState(() { _loadingFiles = true; _filesError = null; });
future.then((files) {
if (mounted) setState(() { _files = files; _loadingFiles = false; });
}).catchError((e) {
if (mounted) setState(() { _filesError = _friendlyError(e); _loadingFiles = false; });
});
}
static String _friendlyError(Object e) {
final s = e.toString();
// Strip full ClientException URL dumps — show only the message part
final msgMatch = RegExp(r'message: ([^,}]+)').firstMatch(s);
if (msgMatch != null) return msgMatch.group(1)!.trim();
if (s.length > 100) return 'Sunucu hatası';
return s;
}
Future<void> _pickAndUpload() async {
final result = await FilePicker.platform.pickFiles(
allowMultiple: true,
withData: true,
type: FileType.custom,
allowedExtensions: [
'pdf', 'jpg', 'jpeg', 'png', 'webp',
'stl', 'obj', 'ply', 'zip', 'opus', 'mp3', 'mp4'
],
);
if (result == null || result.files.isEmpty || !mounted) return;
// Client-side size validation
for (final pf in result.files) {
if (pf.size > _maxFileSizeBytes) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('${pf.name} 50 MB sınırını aşıyor (${_formatSize(pf.size)}).'),
backgroundColor: AppColors.cancelled,
));
return;
}
}
final existingCount = _files?.length ?? 0;
final remaining = _maxFilesPerJob - existingCount;
if (result.files.length > remaining) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Bu iş en fazla $_maxFilesPerJob dosya alabilir. Şu an $existingCount dosya var; en fazla $remaining dosya daha ekleyebilirsiniz.'),
backgroundColor: AppColors.cancelled,
));
return;
}
int uploadedCount = 0;
for (var i = 0; i < result.files.length; i++) {
final pf = result.files[i];
if (pf.bytes == null) continue;
setState(() {
_upload = _UploadState(
fileName: pf.name,
fileIndex: i + 1,
totalFiles: result.files.length,
progress: 0,
speedBytesPerSec: 0,
);
});
try {
await _uploadWithProgress(
pf: pf,
onProgress: (progress, speed) {
if (mounted) {
setState(() {
_upload = _upload?.copyWith(progress: progress, speedBytesPerSec: speed);
});
}
},
);
uploadedCount++;
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${pf.name} yüklenemedi: ${_friendlyError(e)}'), backgroundColor: AppColors.cancelled),
);
}
setState(() => _upload = null);
return;
}
}
setState(() => _upload = null);
widget.onRefresh();
if (mounted && uploadedCount > 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$uploadedCount dosya yüklendi.')),
);
}
}
Future<void> _uploadWithProgress({
required PlatformFile pf,
required void Function(double progress, double speedBytesPerSec) onProgress,
}) async {
final bytes = pf.bytes!;
final pb = PocketBaseClient.instance.pb;
final baseUrl = 'https://pocket.kovaksoft.com';
final uri = Uri.parse('$baseUrl/api/collections/job_files/records');
final ext = (pf.extension ?? '').toLowerCase();
final kind = (ext == 'stl' || ext == 'obj' || ext == 'ply')
? JobFileKind.scan
: (ext == 'pdf') ? JobFileKind.document : JobFileKind.image;
final mimeType = _mimeFromExt(ext);
final currentUserId = (pb.authStore.record?.id) ?? '';
final token = pb.authStore.token;
final startTime = DateTime.now().millisecondsSinceEpoch;
int sentBytes = 0;
Stream<List<int>> progressStream(List<int> src) async* {
const chunkSize = 65536;
var offset = 0;
while (offset < src.length) {
final end = (offset + chunkSize).clamp(0, src.length);
final chunk = src.sublist(offset, end);
yield chunk;
offset = end;
sentBytes = offset;
final elapsedMs = DateTime.now().millisecondsSinceEpoch - startTime;
final speed = elapsedMs > 0 ? sentBytes / elapsedMs * 1000 : 0.0;
onProgress(sentBytes / src.length, speed);
// yield control so Flutter can rebuild
await Future<void>.delayed(Duration.zero);
}
}
final request = http.MultipartRequest('POST', uri)
..headers['Authorization'] = 'Bearer $token'
..fields['job_id'] = widget.job.id
..fields['clinic_tenant_id'] = widget.job.clinicTenantId
..fields['lab_tenant_id'] = widget.job.labTenantId
..fields['uploaded_by'] = currentUserId
..fields['kind'] = kind.value
..fields['name'] = pf.name
..fields['size'] = bytes.length.toString()
..fields['mime_type'] = mimeType
..files.add(http.MultipartFile(
'file',
http.ByteStream(progressStream(bytes)),
bytes.length,
filename: pf.name,
));
final streamed = await request.send();
final body = await streamed.stream.bytesToString();
if (streamed.statusCode < 200 || streamed.statusCode >= 300) {
String msg = 'HTTP ${streamed.statusCode}';
try {
final j = jsonDecode(body) as Map<String, dynamic>;
msg = j['message'] as String? ?? msg;
} catch (_) {}
throw Exception(msg);
}
}
Future<void> _bulkDownload(List<JobFile> files) async {
if (files.isEmpty) return;
final messenger = ScaffoldMessenger.of(context);
try {
final pb = PocketBaseClient.instance.pb;
final fileToken = await pb.files.getToken();
final dir = await getTemporaryDirectory();
await dir.create(recursive: true);
// Download all files in parallel
final results = await Future.wait(
files.where((f) => f.downloadUrl.isNotEmpty).map((file) async {
final uri = Uri.parse('${file.downloadUrl}?token=$fileToken');
final response = await http.get(uri);
if (response.statusCode != 200) return null;
final path = '${dir.path}/${file.name}';
await File(path).writeAsBytes(response.bodyBytes);
return XFile(path, mimeType: file.mimeType ?? 'application/octet-stream');
}),
);
final xFiles = results.whereType<XFile>().toList();
if (xFiles.isEmpty) return;
await Share.shareXFiles(
xFiles,
subject: '${widget.job.patientCode} dosyaları',
sharePositionOrigin: const Rect.fromLTWH(100, 100, 200, 1),
);
} catch (e) {
if (mounted) {
messenger.showSnackBar(
SnackBar(content: Text('İndirilemedi: $e'), backgroundColor: AppColors.cancelled),
);
}
}
}
Future<void> _deleteFile(JobFile file) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Dosyayı Sil'),
content: Text(file.name),
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 || !mounted) return;
// Optimistic: remove immediately from local list
setState(() => _files = _files?.where((f) => f.id != file.id).toList());
try {
await JobFilesRepository.instance.deleteFile(file.id);
} catch (e) {
final is404 = e.toString().contains('404') || e.toString().contains('wasn\'t found');
if (!is404) {
// Revert only on transient errors (network, 500) — not when already deleted
setState(() => _files = [...?_files, file]);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(is404 ? 'Dosya zaten silinmiş.' : 'Silinemedi: ${_friendlyError(e)}'),
backgroundColor: AppColors.cancelled,
),
);
}
}
}
Future<void> _downloadFile(JobFile file, Rect shareOrigin) async {
if (file.downloadUrl.isEmpty) return;
final messenger = ScaffoldMessenger.of(context);
try {
final pb = PocketBaseClient.instance.pb;
final fileToken = await pb.files.getToken();
final uri = Uri.parse('${file.downloadUrl}?token=$fileToken');
final response = await http.get(uri);
if (response.statusCode != 200) {
throw Exception('HTTP ${response.statusCode}');
}
final dir = await getTemporaryDirectory();
await dir.create(recursive: true);
final path = '${dir.path}/${file.name}';
await File(path).writeAsBytes(response.bodyBytes);
await Share.shareXFiles(
[XFile(path, mimeType: file.mimeType ?? 'application/octet-stream')],
subject: file.name,
sharePositionOrigin: shareOrigin,
);
} catch (e) {
if (mounted) {
messenger.showSnackBar(
SnackBar(
content: Text('İndirilemedi: $e'),
backgroundColor: AppColors.cancelled,
),
);
}
}
}
@override
Widget build(BuildContext context) {
final uploading = _upload != null;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.attach_file_rounded, size: 18, color: AppColors.accent),
const SizedBox(width: 6),
const Expanded(
child: Text('Dosyalar', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
),
if (!uploading) ...[
if ((_files?.length ?? 0) >= 2)
TextButton.icon(
onPressed: () => _bulkDownload(_files!),
icon: const Icon(Icons.download_for_offline_outlined, size: 16),
label: const Text('Hepsini İndir'),
style: TextButton.styleFrom(
foregroundColor: AppColors.accent,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
),
TextButton.icon(
onPressed: _pickAndUpload,
icon: const Icon(Icons.upload_rounded, size: 16),
label: const Text('Yükle'),
style: TextButton.styleFrom(
foregroundColor: AppColors.accent,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
),
],
],
),
if (_upload != null) ...[
const SizedBox(height: 10),
_UploadProgressBar(state: _upload!),
],
const SizedBox(height: 8),
Builder(builder: (ctx) {
if (_loadingFiles && _files == null) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
}
if (_filesError != null && _files == null) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'Dosyalar yüklenemedi: $_filesError',
style: const TextStyle(color: AppColors.cancelled, fontSize: 12),
),
);
}
final files = _files ?? [];
if (files.isEmpty && !uploading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text(
'Henüz dosya eklenmemiş.',
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
),
);
}
return Column(
children: files
.map((f) => _FileRow(
file: f,
onDelete: () => _deleteFile(f),
onDownload: (origin) => _downloadFile(f, origin),
))
.toList(),
);
},
),
],
),
);
}
static String _formatSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
static String _mimeFromExt(String ext) => switch (ext) {
'jpg' || 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'webp' => 'image/webp',
'pdf' => 'application/pdf',
'stl' => 'model/stl',
'zip' => 'application/zip',
'mp3' => 'audio/mpeg',
'mp4' => 'video/mp4',
'opus' => 'audio/opus',
_ => 'application/octet-stream',
};
}
// ── Upload Progress Bar ───────────────────────────────────────────────────────
class _UploadProgressBar extends StatelessWidget {
const _UploadProgressBar({required this.state});
final _UploadState state;
@override
Widget build(BuildContext context) {
final pct = (state.progress * 100).toStringAsFixed(0);
final speed = state.speedBytesPerSec;
final speedLabel = speed >= 1024 * 1024
? '${(speed / 1024 / 1024).toStringAsFixed(1)} MB/s'
: '${(speed / 1024).toStringAsFixed(0)} KB/s';
final fileLabel = state.totalFiles > 1
? '${state.fileIndex}/${state.totalFiles}${state.fileName}'
: state.fileName;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
fileLabel,
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Text(
'$pct% · $speedLabel',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.accent,
fontFeatures: [FontFeature.tabularFigures()],
),
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: state.progress,
minHeight: 6,
backgroundColor: AppColors.background,
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.accent),
),
),
],
);
}
}
// ── File Row ──────────────────────────────────────────────────────────────────
class _FileRow extends StatefulWidget {
const _FileRow({
required this.file,
required this.onDelete,
required this.onDownload,
});
final JobFile file;
final VoidCallback onDelete;
final Future<void> Function(Rect) onDownload;
@override
State<_FileRow> createState() => _FileRowState();
}
class _FileRowState extends State<_FileRow> {
bool _downloading = false;
IconData get _icon => switch (widget.file.kind) {
JobFileKind.scan => Icons.view_in_ar_rounded,
JobFileKind.image => Icons.image_outlined,
JobFileKind.document => Icons.description_outlined,
};
final _downloadKey = GlobalKey();
Future<void> _handleDownload() async {
setState(() => _downloading = true);
final box = _downloadKey.currentContext?.findRenderObject() as RenderBox?;
final origin = box != null
? box.localToGlobal(Offset.zero) & box.size
: const Rect.fromLTWH(100, 100, 200, 1);
try {
await widget.onDownload(origin);
} finally {
if (mounted) setState(() => _downloading = false);
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Row(
children: [
Icon(_icon, size: 18, color: AppColors.textMuted),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.file.name,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'${widget.file.kind.label} · ${widget.file.sizeLabel}',
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
),
],
),
),
if (_downloading)
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.accent),
)
else
IconButton(
key: _downloadKey,
onPressed: _handleDownload,
icon: const Icon(Icons.download_outlined, size: 18, color: AppColors.accent),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
tooltip: 'İndir',
),
const SizedBox(width: 4),
IconButton(
onPressed: widget.onDelete,
icon: const Icon(Icons.delete_outline_rounded, size: 18, color: AppColors.cancelled),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
tooltip: 'Sil',
),
],
),
);
}
}
// ── Upload State ──────────────────────────────────────────────────────────────
class _UploadState {
const _UploadState({
required this.fileName,
required this.fileIndex,
required this.totalFiles,
required this.progress,
required this.speedBytesPerSec,
});
final String fileName;
final int fileIndex;
final int totalFiles;
final double progress;
final double speedBytesPerSec;
_UploadState copyWith({double? progress, double? speedBytesPerSec}) =>
_UploadState(
fileName: fileName,
fileIndex: fileIndex,
totalFiles: totalFiles,
progress: progress ?? this.progress,
speedBytesPerSec: speedBytesPerSec ?? this.speedBytesPerSec,
);
}
@@ -0,0 +1,59 @@
import 'package:http/http.dart' as http;
import 'package:pocketbase/pocketbase.dart';
import '../../core/api/pocketbase_client.dart';
import '../../models/job_file.dart';
class JobFilesRepository {
JobFilesRepository._();
static final instance = JobFilesRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
static const _baseUrl = 'https://pocket.kovaksoft.com';
String get _currentUserId => (_pb.authStore.record?.id) ?? '';
Future<List<JobFile>> listForJob(String jobId) async {
final result = await _pb.collection('job_files').getList(
filter: 'job_id = "$jobId"',
perPage: 200,
);
final files = result.items.map((r) => JobFile.fromJson(r.toJson(), _baseUrl)).toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return files;
}
Future<JobFile> uploadFile({
required String jobId,
required String clinicTenantId,
required String labTenantId,
required JobFileKind kind,
required String name,
required int size,
required List<int> bytes,
String? mimeType,
}) async {
final multipartFile = http.MultipartFile.fromBytes(
'file',
bytes,
filename: name,
);
final record = await _pb.collection('job_files').create(
body: {
'job_id': jobId,
'clinic_tenant_id': clinicTenantId,
'lab_tenant_id': labTenantId,
'uploaded_by': _currentUserId,
'kind': kind.value,
'name': name,
'size': size,
if (mimeType != null) 'mime_type': mimeType,
},
files: [multipartFile],
);
return JobFile.fromJson(record.toJson(), _baseUrl);
}
Future<void> deleteFile(String fileId) async {
await _pb.collection('job_files').delete(fileId);
}
}
+325
View File
@@ -0,0 +1,325 @@
import 'package:pocketbase/pocketbase.dart';
import '../../core/api/pocketbase_client.dart';
import '../../models/tenant.dart';
// ── Value objects ─────────────────────────────────────────────────────────────
const _monthLabels = ['Oca','Şub','Mar','Nis','May','Haz','Tem','Ağu','Eyl','Eki','Kas','Ara'];
class MonthlyCount {
const MonthlyCount({required this.year, required this.month, required this.count});
final int year, month, count;
String get label => _monthLabels[month - 1];
}
class MonthlyAmount {
const MonthlyAmount({required this.year, required this.month, required this.amount});
final int year, month;
final double amount;
String get label => _monthLabels[month - 1];
}
class CounterpartStat {
const CounterpartStat({required this.name, required this.jobCount, required this.pendingRevenue, required this.paidRevenue});
final String name;
final int jobCount;
final double pendingRevenue, paidRevenue;
double get totalRevenue => pendingRevenue + paidRevenue;
}
class ActivityItem {
const ActivityItem({required this.jobId, this.patientCode, required this.action, required this.createdAt, this.note});
final String jobId, action;
final String? patientCode, note;
final DateTime createdAt;
String get actionLabel => switch (action) {
'accepted' => 'İş kabul edildi',
'handed_to_clinic' => 'Provaya gönderildi',
'approved' => 'Onaylandı',
'revision_requested' => 'Revizyon istendi',
'delivered' => 'Teslim edildi',
'cancelled' => 'İptal edildi',
_ => action,
};
bool get isNegative => action == 'revision_requested' || action == 'cancelled';
bool get isPositive => action == 'delivered' || action == 'approved' || action == 'accepted';
}
// ── Aggregated metrics ────────────────────────────────────────────────────────
class ReportMetrics {
const ReportMetrics({
required this.activeJobs,
required this.completedThisMonth,
required this.overdueJobs,
required this.revisionRate,
required this.avgCompletionDays,
required this.totalRevenue,
required this.pendingRevenue,
required this.currency,
required this.jobsByStatus,
required this.monthlyCounts,
required this.monthlyRevenue,
required this.byProstheticType,
required this.counterpartStats,
required this.recentActivity,
});
final int activeJobs;
final int completedThisMonth;
final int overdueJobs;
final double revisionRate; // 0-100
final double avgCompletionDays;
final double totalRevenue;
final double pendingRevenue;
final String currency;
final Map<String, int> jobsByStatus;
final List<MonthlyCount> monthlyCounts;
final List<MonthlyAmount> monthlyRevenue;
final Map<String, int> byProstheticType;
final List<CounterpartStat> counterpartStats;
final List<ActivityItem> recentActivity;
}
// ── Repository ────────────────────────────────────────────────────────────────
class ReportsRepository {
ReportsRepository._();
static final instance = ReportsRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<ReportMetrics> load(String tenantId, TenantKind kind) async {
final jobFilter = kind == TenantKind.lab
? 'lab_tenant_id = "$tenantId"'
: 'clinic_tenant_id = "$tenantId"';
final historyFilter = kind == TenantKind.lab
? 'lab_tenant_id = "$tenantId"'
: 'clinic_tenant_id = "$tenantId"';
final results = await Future.wait([
_pb.collection('jobs').getList(
filter: jobFilter,
perPage: 500,
expand: kind == TenantKind.lab ? 'clinic_tenant_id' : 'lab_tenant_id',
fields: 'id,status,prosthetic_type,clinic_tenant_id,lab_tenant_id,created,updated,due_date,price,currency,expand',
).catchError((_) => ResultList<RecordModel>()),
_pb.collection('finance_entries').getList(
filter: 'tenant_id = "$tenantId"',
perPage: 300,
fields: 'id,amount,currency,status,created,counterparty_name',
).catchError((_) => ResultList<RecordModel>()),
_pb.collection('job_status_history').getList(
filter: historyFilter,
perPage: 100,
expand: 'job_id',
fields: 'id,action_type,created,note,job_id,expand',
).catchError((_) => ResultList<RecordModel>()),
]);
final jobRecords = (results[0] as ResultList<RecordModel>).items;
final financeRecords = (results[1] as ResultList<RecordModel>).items;
final historyRecords = (results[2] as ResultList<RecordModel>).items;
return _aggregate(tenantId, kind, jobRecords, financeRecords, historyRecords);
}
ReportMetrics _aggregate(
String tenantId,
TenantKind kind,
List<RecordModel> jobRecords,
List<RecordModel> financeRecords,
List<RecordModel> historyRecords,
) {
final now = DateTime.now();
final thisMonthStart = DateTime(now.year, now.month, 1);
// ── Parse jobs ────────────────────────────────────────────────────────────
String _s(Map<String, dynamic> j, String k) {
final v = j[k];
if (v == null || v == '') return '';
return v.toString();
}
final jobs = jobRecords.map((r) {
final j = r.toJson();
final exp = j['expand'] as Map<String, dynamic>?;
final cpKey = kind == TenantKind.lab ? 'clinic_tenant_id' : 'lab_tenant_id';
final cpExp = exp?[cpKey] as Map<String, dynamic>?;
return _RawJob(
id: _s(j, 'id'),
status: _s(j, 'status'),
prostheticType: _s(j, 'prosthetic_type'),
clinicTenantId: _s(j, 'clinic_tenant_id'),
labTenantId: _s(j, 'lab_tenant_id'),
created: _parseDate(j['created']),
updated: _parseDate(j['updated']),
dueDate: j['due_date'] != null && j['due_date'] != '' ? _parseDate(j['due_date']) : null,
currency: _s(j, 'currency').isNotEmpty ? _s(j, 'currency') : 'TRY',
counterpartName: cpExp?['company_name'] as String? ?? '',
);
}).toList();
// ── Parse finance ─────────────────────────────────────────────────────────
String topCurrency = 'TRY';
final financeList = financeRecords.map((r) {
final j = r.toJson();
final cur = (j['currency'] as String?) ?? 'TRY';
if (cur.isNotEmpty) topCurrency = cur;
return _RawFinance(
status: (j['status'] as String?) ?? '',
amount: (j['amount'] as num?)?.toDouble() ?? 0,
created: _parseDate(j['created']),
counterpartyName: j['counterparty_name'] as String?,
);
}).toList();
// ── Parse history ─────────────────────────────────────────────────────────
final activity = historyRecords.map((r) {
final j = r.toJson();
final exp = j['expand'] as Map<String, dynamic>?;
final jobExp = exp?['job_id'] as Map<String, dynamic>?;
return ActivityItem(
jobId: (j['job_id'] as String?) ?? '',
patientCode: jobExp?['patient_code'] as String?,
action: (j['action_type'] as String?) ?? '',
createdAt: _parseDate(j['created']),
note: j['note'] as String?,
);
}).toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
// ── KPI metrics ───────────────────────────────────────────────────────────
final activeJobs = jobs.where((j) => j.status == 'in_progress' || j.status == 'pending').length;
final completedThisMonth = jobs.where((j) => j.status == 'delivered' && j.updated.isAfter(thisMonthStart)).length;
final overdueJobs = jobs.where((j) =>
j.dueDate != null &&
j.dueDate!.isBefore(now) &&
(j.status == 'in_progress' || j.status == 'pending')).length;
final revisions = activity.where((a) => a.action == 'revision_requested').length;
final revisionRate = activity.isNotEmpty ? revisions / activity.length * 100 : 0.0;
final deliveredJobs = jobs.where((j) => j.status == 'delivered').toList();
final avgCompletionDays = deliveredJobs.isNotEmpty
? deliveredJobs
.fold<int>(0, (s, j) => s + j.updated.difference(j.created).inDays) /
deliveredJobs.length
: 0.0;
// ── Finance totals ────────────────────────────────────────────────────────
final totalRevenue = financeList.where((f) => f.status == 'paid').fold<double>(0, (s, f) => s + f.amount);
final pendingRevenue = financeList.where((f) => f.status == 'pending').fold<double>(0, (s, f) => s + f.amount);
// ── Job status distribution ───────────────────────────────────────────────
final Map<String, int> jobsByStatus = {};
for (final j in jobs) {
jobsByStatus[j.status] = (jobsByStatus[j.status] ?? 0) + 1;
}
// ── Monthly job counts (last 6 months) ────────────────────────────────────
final monthKeys = List.generate(6, (i) {
final d = DateTime(now.year, now.month - 5 + i, 1);
return '${d.year}-${d.month}';
});
final monthMap = {for (final k in monthKeys) k: 0};
for (final j in jobs) {
final key = '${j.created.year}-${j.created.month}';
if (monthMap.containsKey(key)) monthMap[key] = monthMap[key]! + 1;
}
final monthlyCounts = monthKeys.map((k) {
final parts = k.split('-');
return MonthlyCount(year: int.parse(parts[0]), month: int.parse(parts[1]), count: monthMap[k]!);
}).toList();
// ── Monthly revenue (last 6 months) ──────────────────────────────────────
final revMap = {for (final k in monthKeys) k: 0.0};
for (final f in financeList) {
if (f.status == 'paid') {
final key = '${f.created.year}-${f.created.month}';
if (revMap.containsKey(key)) revMap[key] = revMap[key]! + f.amount;
}
}
final monthlyRevenue = monthKeys.map((k) {
final parts = k.split('-');
return MonthlyAmount(year: int.parse(parts[0]), month: int.parse(parts[1]), amount: revMap[k]!);
}).toList();
// ── By prosthetic type ────────────────────────────────────────────────────
final Map<String, int> byType = {};
for (final j in jobs) {
if (j.prostheticType.isNotEmpty) {
byType[j.prostheticType] = (byType[j.prostheticType] ?? 0) + 1;
}
}
// ── By counterpart ────────────────────────────────────────────────────────
final Map<String, int> cpCount = {};
final Map<String, double> cpPending = {}, cpPaid = {};
for (final j in jobs) {
final name = j.counterpartName.isNotEmpty ? j.counterpartName : '';
cpCount[name] = (cpCount[name] ?? 0) + 1;
}
for (final f in financeList) {
final name = f.counterpartyName ?? '';
if (f.status == 'pending') cpPending[name] = (cpPending[name] ?? 0) + f.amount;
if (f.status == 'paid') cpPaid[name] = (cpPaid[name] ?? 0) + f.amount;
}
final counterparts = cpCount.entries
.map((e) => CounterpartStat(
name: e.key,
jobCount: e.value,
pendingRevenue: cpPending[e.key] ?? 0,
paidRevenue: cpPaid[e.key] ?? 0,
))
.toList()
..sort((a, b) => b.jobCount.compareTo(a.jobCount));
return ReportMetrics(
activeJobs: activeJobs,
completedThisMonth: completedThisMonth,
overdueJobs: overdueJobs,
revisionRate: revisionRate,
avgCompletionDays: avgCompletionDays,
totalRevenue: totalRevenue,
pendingRevenue: pendingRevenue,
currency: topCurrency,
jobsByStatus: jobsByStatus,
monthlyCounts: monthlyCounts,
monthlyRevenue: monthlyRevenue,
byProstheticType: byType,
counterpartStats: counterparts.take(5).toList(),
recentActivity: activity.take(30).toList(),
);
}
static DateTime _parseDate(dynamic v) {
if (v == null || v == '') return DateTime(2000);
return DateTime.tryParse(v.toString()) ?? DateTime(2000);
}
}
// ── Internal raw models ───────────────────────────────────────────────────────
class _RawJob {
const _RawJob({
required this.id, required this.status, required this.prostheticType,
required this.clinicTenantId, required this.labTenantId,
required this.created, required this.updated,
required this.currency, required this.counterpartName, this.dueDate,
});
final String id, status, prostheticType, clinicTenantId, labTenantId, currency, counterpartName;
final DateTime created, updated;
final DateTime? dueDate;
}
class _RawFinance {
const _RawFinance({required this.status, required this.amount, required this.created, this.counterpartyName});
final String status;
final double amount;
final DateTime created;
final String? counterpartyName;
}
+690
View File
@@ -0,0 +1,690 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../core/providers/auth_provider.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/gradient_app_bar.dart';
import '../../models/job.dart';
import '../../models/tenant.dart';
import 'reports_repository.dart';
class ReportsScreen extends ConsumerStatefulWidget {
const ReportsScreen({super.key});
@override
ConsumerState<ReportsScreen> createState() => _ReportsScreenState();
}
class _ReportsScreenState extends ConsumerState<ReportsScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
late Future<ReportMetrics> _future;
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
_tabController.addListener(() { if (mounted) setState(() {}); });
_load();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _load() {
final auth = ref.read(authProvider);
final tenantId = auth.activeTenant!.tenant.id;
final kind = auth.activeTenant!.tenant.kind;
setState(() {
_future = ReportsRepository.instance.load(tenantId, kind);
});
}
@override
Widget build(BuildContext context) {
final kind = ref.watch(authProvider).activeTenant?.tenant.kind ?? TenantKind.lab;
final counterpartLabel = kind == TenantKind.lab ? 'Klinikler' : 'Laboratuvarlar';
return Scaffold(
backgroundColor: AppColors.background,
appBar: GradientAppBar(
title: 'Raporlar',
category: 'YÖNETİCİ',
actions: [
IconButton(
icon: const Icon(Icons.refresh_rounded),
onPressed: _load,
tooltip: 'Yenile',
),
],
),
body: FutureBuilder<ReportMetrics>(
future: _future,
builder: (context, 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.error_outline_rounded, color: AppColors.cancelled, size: 48),
const SizedBox(height: 12),
Text('Veriler yüklenemedi', style: const TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Tekrar Dene'),
),
],
),
);
}
final m = snap.data!;
return Column(
children: [
// Tab bar
Container(
color: AppColors.surface,
child: TabBar(
controller: _tabController,
labelColor: AppColors.accent,
unselectedLabelColor: AppColors.textSecondary,
indicatorColor: AppColors.accent,
indicatorSize: TabBarIndicatorSize.tab,
labelStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
tabs: [
const Tab(text: 'Özet'),
const Tab(text: 'Finans'),
const Tab(text: 'Aktivite'),
Tab(text: counterpartLabel),
],
),
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_SummaryTab(metrics: m),
_FinanceTab(metrics: m),
_ActivityTab(metrics: m),
_CounterpartTab(metrics: m, label: counterpartLabel),
],
),
),
],
);
},
),
);
}
}
// ── Shared layout helpers ─────────────────────────────────────────────────────
class _TabBody extends StatelessWidget {
const _TabBody({required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) => ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
children: children,
);
}
class _Card extends StatelessWidget {
const _Card({required this.child, this.padding = const EdgeInsets.all(16)});
final Widget child;
final EdgeInsets padding;
@override
Widget build(BuildContext context) => Container(
margin: const EdgeInsets.only(bottom: 12),
padding: padding,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 8, offset: const Offset(0, 2))],
),
child: child,
);
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader(this.title, {this.subtitle});
final String title;
final String? subtitle;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(bottom: 10, top: 4),
child: Row(
children: [
Expanded(
child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
),
if (subtitle != null)
Text(subtitle!, style: const TextStyle(fontSize: 12, color: AppColors.textMuted)),
],
),
);
}
// ── KPI Chips ─────────────────────────────────────────────────────────────────
class _KpiRow extends StatelessWidget {
const _KpiRow({required this.metrics});
final ReportMetrics metrics;
@override
Widget build(BuildContext context) {
final fmt = NumberFormat.currency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0);
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
_Kpi(label: 'Aktif İşler', value: '${metrics.activeJobs}', icon: Icons.work_outline_rounded, color: AppColors.inProgress),
const SizedBox(width: 10),
_Kpi(label: 'Bu Ay Tamamlandı', value: '${metrics.completedThisMonth}', icon: Icons.check_circle_outline_rounded, color: AppColors.success),
const SizedBox(width: 10),
_Kpi(label: 'Bekleyen Gelir', value: fmt.format(metrics.pendingRevenue), icon: Icons.hourglass_empty_rounded, color: AppColors.pending),
const SizedBox(width: 10),
_Kpi(label: 'Ort. Süre', value: '${metrics.avgCompletionDays.toStringAsFixed(1)} gün', icon: Icons.timer_outlined, color: AppColors.accent),
const SizedBox(width: 10),
_Kpi(label: 'Revizyon Oranı', value: '%${metrics.revisionRate.toStringAsFixed(0)}', icon: Icons.loop_rounded, color: metrics.revisionRate > 20 ? AppColors.cancelled : AppColors.textSecondary),
if (metrics.overdueJobs > 0) ...[
const SizedBox(width: 10),
_Kpi(label: 'Gecikmiş', value: '${metrics.overdueJobs}', icon: Icons.schedule_rounded, color: AppColors.cancelled),
],
],
),
);
}
}
class _Kpi extends StatelessWidget {
const _Kpi({required this.label, required this.value, required this.icon, required this.color});
final String label, value;
final IconData icon;
final Color color;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 6)],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 4),
Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textMuted)),
],
),
const SizedBox(height: 6),
Text(value, style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: color)),
],
),
);
}
// ── Özet Tab ──────────────────────────────────────────────────────────────────
class _SummaryTab extends StatelessWidget {
const _SummaryTab({required this.metrics});
final ReportMetrics metrics;
@override
Widget build(BuildContext context) {
return _TabBody(children: [
_KpiRow(metrics: metrics),
const _SectionHeader('İş Durumu Dağılımı'),
_Card(
child: Column(
children: _statusOrder.where((s) => metrics.jobsByStatus.containsKey(s)).map((s) {
final count = metrics.jobsByStatus[s] ?? 0;
final total = metrics.jobsByStatus.values.fold(0, (a, b) => a + b);
return _HBarRow(
label: _statusLabel(s),
value: count,
max: total,
color: _statusColor(s),
);
}).toList(),
),
),
const _SectionHeader('Son 6 Aylık İş Trendi'),
_Card(child: _VBarChart(data: metrics.monthlyCounts, color: AppColors.accent)),
]);
}
static const _statusOrder = ['in_progress', 'pending', 'sent', 'delivered', 'cancelled'];
static String _statusLabel(String s) => switch (s) {
'pending' => 'Bekliyor',
'in_progress' => 'İşlemde',
'sent' => 'Gönderildi',
'delivered' => 'Teslim',
'cancelled' => 'İptal',
_ => s,
};
static Color _statusColor(String s) => switch (s) {
'pending' => AppColors.pending,
'in_progress' => AppColors.inProgress,
'sent' => AppColors.accent,
'delivered' => AppColors.success,
'cancelled' => AppColors.cancelled,
_ => AppColors.textMuted,
};
}
// ── Finans Tab ────────────────────────────────────────────────────────────────
class _FinanceTab extends StatelessWidget {
const _FinanceTab({required this.metrics});
final ReportMetrics metrics;
@override
Widget build(BuildContext context) {
final fmt = NumberFormat.currency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0);
final total = metrics.totalRevenue + metrics.pendingRevenue;
return _TabBody(children: [
const _SectionHeader('Gelir Özeti'),
Row(
children: [
Expanded(
child: _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(width: 8, height: 8, decoration: BoxDecoration(color: AppColors.success, shape: BoxShape.circle)),
const SizedBox(width: 6),
const Text('Tahsil Edildi', style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
]),
const SizedBox(height: 6),
Text(fmt.format(metrics.totalRevenue),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.success)),
],
),
),
),
const SizedBox(width: 10),
Expanded(
child: _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(width: 8, height: 8, decoration: BoxDecoration(color: AppColors.pending, shape: BoxShape.circle)),
const SizedBox(width: 6),
const Text('Bekleyen', style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
]),
const SizedBox(height: 6),
Text(fmt.format(metrics.pendingRevenue),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.pending)),
],
),
),
),
],
),
if (total > 0) _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Tahsilat Oranı', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: total > 0 ? metrics.totalRevenue / total : 0,
minHeight: 12,
backgroundColor: AppColors.pendingBg,
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.success),
),
),
const SizedBox(height: 6),
Text('${(metrics.totalRevenue / total * 100).toStringAsFixed(0)}% tahsil edildi',
style: const TextStyle(fontSize: 12, color: AppColors.textMuted)),
],
),
),
const SizedBox(height: 4),
const _SectionHeader('Aylık Gelir Trendi'),
_Card(child: _VBarChart(
data: metrics.monthlyRevenue.map((m) => MonthlyCount(year: m.year, month: m.month, count: m.amount.round())).toList(),
color: AppColors.success,
formatValue: (v) => NumberFormat.compactCurrency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0).format(v),
)),
]);
}
}
// ── Aktivite Tab ──────────────────────────────────────────────────────────────
class _ActivityTab extends StatelessWidget {
const _ActivityTab({required this.metrics});
final ReportMetrics metrics;
@override
Widget build(BuildContext context) {
final items = metrics.recentActivity;
if (items.isEmpty) {
return const Center(
child: Text('Henüz aktivite kaydı yok.', style: TextStyle(color: AppColors.textMuted)),
);
}
return _TabBody(children: [
const _SectionHeader('Son İşlemler'),
_Card(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Column(
children: items.asMap().entries.map((entry) {
final i = entry.key;
final item = entry.value;
return _ActivityRow(item: item, isLast: i == items.length - 1);
}).toList(),
),
),
]);
}
}
class _ActivityRow extends StatelessWidget {
const _ActivityRow({required this.item, required this.isLast});
final ActivityItem item;
final bool isLast;
@override
Widget build(BuildContext context) {
final color = item.isNegative ? AppColors.cancelled : item.isPositive ? AppColors.success : AppColors.accent;
final icon = switch (item.action) {
'accepted' => Icons.check_circle_outline_rounded,
'handed_to_clinic' => Icons.send_rounded,
'approved' => Icons.thumb_up_outlined,
'revision_requested' => Icons.loop_rounded,
'delivered' => Icons.local_shipping_outlined,
'cancelled' => Icons.cancel_outlined,
_ => Icons.history_rounded,
};
final df = DateFormat('dd.MM.yy HH:mm');
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Timeline line + dot
SizedBox(
width: 28,
child: Column(
children: [
Container(
width: 24, height: 24,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
shape: BoxShape.circle,
),
child: Icon(icon, size: 13, color: color),
),
if (!isLast)
Expanded(child: Container(width: 1.5, color: AppColors.border)),
],
),
),
const SizedBox(width: 10),
Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: isLast ? 0 : 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.actionLabel,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
if (item.patientCode != null && item.patientCode!.isNotEmpty)
Text(item.patientCode!, style: const TextStyle(fontSize: 11, color: AppColors.accent)),
if (item.note != null && item.note!.isNotEmpty)
Text(item.note!, style: const TextStyle(fontSize: 11, color: AppColors.textMuted)),
const SizedBox(height: 2),
Text(df.format(item.createdAt), style: const TextStyle(fontSize: 10, color: AppColors.textMuted)),
],
),
),
),
],
),
);
}
}
// ── Counterpart Tab ───────────────────────────────────────────────────────────
class _CounterpartTab extends StatelessWidget {
const _CounterpartTab({required this.metrics, required this.label});
final ReportMetrics metrics;
final String label;
@override
Widget build(BuildContext context) {
final stats = metrics.counterpartStats;
if (stats.isEmpty) {
return Center(
child: Text('$label henüz yok.', style: const TextStyle(color: AppColors.textMuted)),
);
}
final fmt = NumberFormat.currency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0);
final maxJobs = stats.fold(0, (m, s) => s.jobCount > m ? s.jobCount : m);
return _TabBody(children: [
const _SectionHeader('Protez Türü Dağılımı'),
_Card(
child: Column(
children: _buildTypeRows(metrics.byProstheticType),
),
),
_SectionHeader('En Aktif $label'),
_Card(
child: Column(
children: stats.asMap().entries.map((entry) {
final i = entry.key;
final s = entry.value;
return Padding(
padding: EdgeInsets.only(bottom: i < stats.length - 1 ? 12 : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 22, height: 22,
decoration: BoxDecoration(
color: AppColors.inProgressBg,
shape: BoxShape.circle,
),
child: Center(
child: Text('${i + 1}',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.inProgress)),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(s.name,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
maxLines: 1, overflow: TextOverflow.ellipsis),
),
Text('${s.jobCount}',
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary, fontWeight: FontWeight.w500)),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: maxJobs > 0 ? s.jobCount / maxJobs : 0,
minHeight: 6,
backgroundColor: AppColors.surfaceVariant,
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.accent),
),
),
if (s.totalRevenue > 0) Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'${fmt.format(s.paidRevenue)} tahsil · ${fmt.format(s.pendingRevenue)} bekliyor',
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
),
),
],
),
);
}).toList(),
),
),
]);
}
List<Widget> _buildTypeRows(Map<String, int> byType) {
if (byType.isEmpty) return [const Text('Veri yok', style: TextStyle(color: AppColors.textMuted))];
final sorted = byType.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
final max = sorted.first.value;
return sorted.map((e) => _HBarRow(
label: _typeLabel(e.key),
value: e.value,
max: max,
color: AppColors.accent,
)).toList();
}
static String _typeLabel(String s) => switch (s) {
'metal_porselen' => 'Metal Porselen',
'zirkonyum' => 'Zirkonyum',
'implant_ustu_zirkonyum'=> 'İmplant Üstü',
'gecici' => 'Geçici',
'e_max' => 'E-Max',
'tam_protez' => 'Tam Protez',
'parsiyel' => 'Parsiyel',
_ => 'Diğer',
};
}
// ── Chart widgets ─────────────────────────────────────────────────────────────
class _HBarRow extends StatelessWidget {
const _HBarRow({required this.label, required this.value, required this.max, required this.color});
final String label;
final int value, max;
final Color color;
@override
Widget build(BuildContext context) {
final fraction = max > 0 ? value / max : 0.0;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Row(
children: [
SizedBox(
width: 100,
child: Text(label,
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
maxLines: 1, overflow: TextOverflow.ellipsis),
),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Stack(
children: [
Container(height: 22, color: AppColors.surfaceVariant),
FractionallySizedBox(
widthFactor: fraction,
child: Container(
height: 22,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(4),
),
),
),
],
),
),
),
const SizedBox(width: 8),
SizedBox(
width: 28,
child: Text('$value',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.textPrimary),
textAlign: TextAlign.right),
),
],
),
);
}
}
class _VBarChart extends StatelessWidget {
const _VBarChart({required this.data, required this.color, this.formatValue});
final List<MonthlyCount> data;
final Color color;
final String Function(int)? formatValue;
@override
Widget build(BuildContext context) {
if (data.isEmpty) return const SizedBox.shrink();
final maxVal = data.fold(0, (m, e) => e.count > m ? e.count : m);
final fmt = formatValue ?? (v) => '$v';
return SizedBox(
height: 140,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: data.map((d) {
final fraction = maxVal > 0 ? d.count / maxVal : 0.0;
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (d.count > 0)
Text(fmt(d.count),
style: const TextStyle(fontSize: 9, color: AppColors.textMuted),
textAlign: TextAlign.center),
const SizedBox(height: 2),
AnimatedContainer(
duration: const Duration(milliseconds: 600),
curve: Curves.easeOut,
height: (fraction * 90).clamp(2, 90),
decoration: BoxDecoration(
color: color,
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
),
),
const SizedBox(height: 4),
Text(d.label,
style: const TextStyle(fontSize: 10, color: AppColors.textMuted),
textAlign: TextAlign.center),
],
),
),
);
}).toList(),
),
);
}
}
@@ -0,0 +1,82 @@
import 'package:pocketbase/pocketbase.dart';
import '../../core/api/pocketbase_client.dart';
import '../../models/tenant.dart';
import '../../models/user_profile.dart';
class TeamMember {
const TeamMember({
required this.memberId,
required this.user,
required this.role,
required this.joinedAt,
});
final String memberId;
final UserProfile user;
final TenantRole role;
final DateTime joinedAt;
}
class TenantTeamRepository {
TenantTeamRepository._();
static final instance = TenantTeamRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<List<TeamMember>> listMembers(String tenantId) async {
final result = await _pb.collection('tenant_members').getList(
filter: 'tenant_id = "$tenantId"',
expand: 'user_id',
perPage: 200,
);
return (result.items.map((r) {
final j = r.toJson();
final userExp = (j['expand'] as Map?)?['user_id'] as Map<String, dynamic>?;
return TeamMember(
memberId: j['id'] as String,
user: UserProfile.fromJson(userExp ?? {'id': j['user_id'], 'email': ''}),
role: TenantMembership.parseRole(j['role'] as String),
joinedAt: DateTime.parse(j['created'] as String),
);
}).toList()..sort((a, b) => a.joinedAt.compareTo(b.joinedAt)));
}
Future<TeamMember> addMember({
required String tenantId,
required String email,
required String password,
required String firstName,
required String lastName,
required TenantRole role,
}) async {
final userRecord = await _pb.collection('users').create(body: {
'email': email.trim().toLowerCase(),
'password': password,
'passwordConfirm': password,
'first_name': firstName.trim(),
'last_name': lastName.trim(),
'emailVisibility': true,
});
final memberRecord = await _pb.collection('tenant_members').create(body: {
'tenant_id': tenantId,
'user_id': userRecord.id,
'role': role.value,
});
final j = memberRecord.toJson();
return TeamMember(
memberId: j['id'] as String,
user: UserProfile.fromJson(userRecord.toJson()),
role: role,
joinedAt: DateTime.parse(j['created'] as String),
);
}
Future<void> changeMemberRole(String memberId, TenantRole newRole) async {
await _pb.collection('tenant_members').update(memberId, body: {
'role': newRole.value,
});
}
Future<void> removeMember(String memberId) async {
await _pb.collection('tenant_members').delete(memberId);
}
}
+742
View File
@@ -0,0 +1,742 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/providers/auth_provider.dart';
import '../../core/theme/app_theme.dart';
import '../../models/tenant.dart';
import 'tenant_team_repository.dart';
class TenantTeamScreen extends ConsumerStatefulWidget {
const TenantTeamScreen({super.key});
@override
ConsumerState<TenantTeamScreen> createState() => _TenantTeamScreenState();
}
class _TenantTeamScreenState extends ConsumerState<TenantTeamScreen> {
List<TeamMember> _members = [];
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
final members = await TenantTeamRepository.instance.listMembers(tenantId);
if (mounted) {
setState(() {
_members = members;
_loading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_loading = false;
});
}
}
}
bool get _canManage =>
ref.read(authProvider).activeTenant?.canManageUsers ?? false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Ekip'),
actions: [
if (_canManage)
TextButton.icon(
onPressed: () => _showAddMemberSheet(context),
icon: const Icon(Icons.person_add_outlined, size: 18),
label: const Text('Üye Ekle'),
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? _ErrorView(error: _error!, onRetry: _load)
: RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_SectionHeader(
title: 'Üyeler',
count: _members.length,
),
const SizedBox(height: 8),
_MembersList(
members: _members,
canManage: _canManage,
currentUserId:
ref.read(authProvider).profile?.id ?? '',
onRoleChange: _changeRole,
onRemove: _removeMember,
),
const SizedBox(height: 24),
],
),
),
);
}
Future<void> _showAddMemberSheet(BuildContext context) async {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: AppColors.background,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => _AddMemberSheet(
onAdd: (firstName, lastName, email, password, role) async {
await TenantTeamRepository.instance.addMember(
tenantId: tenantId,
email: email,
password: password,
firstName: firstName,
lastName: lastName,
role: role,
);
await _load();
},
),
);
}
Future<void> _changeRole(TeamMember member, TenantRole newRole) async {
try {
await TenantTeamRepository.instance.changeMemberRole(
member.memberId, newRole);
await _load();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Hata: $e')));
}
}
}
Future<void> _removeMember(TeamMember member) async {
final name = member.user.displayName.isNotEmpty
? member.user.displayName
: member.user.email;
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Üyeyi Çıkar'),
content: Text('$name adlı üyeyi ekipten çıkarmak istiyor musunuz?'),
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('Çıkar'),
),
],
),
);
if (confirmed != true) return;
try {
await TenantTeamRepository.instance.removeMember(member.memberId);
await _load();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Hata: $e')));
}
}
}
}
// ── Section header ─────────────────────────────────────────────────────────
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.title, required this.count});
final String title;
final int count;
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(
title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textSecondary,
letterSpacing: 0.5,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.inProgressBg,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'$count',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppColors.inProgress,
),
),
),
],
);
}
}
// ── Members list ───────────────────────────────────────────────────────────
class _MembersList extends StatelessWidget {
const _MembersList({
required this.members,
required this.canManage,
required this.currentUserId,
required this.onRoleChange,
required this.onRemove,
});
final List<TeamMember> members;
final bool canManage;
final String currentUserId;
final Future<void> Function(TeamMember, TenantRole) onRoleChange;
final Future<void> Function(TeamMember) onRemove;
@override
Widget build(BuildContext context) {
if (members.isEmpty) {
return const _EmptyCard(message: 'Henüz üye yok.');
}
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: members.asMap().entries.map((entry) {
final i = entry.key;
final m = entry.value;
final isLast = i == members.length - 1;
return _MemberTile(
member: m,
isSelf: m.user.id == currentUserId,
canManage: canManage && m.role != TenantRole.owner,
showDivider: !isLast,
onRoleChange: (role) => onRoleChange(m, role),
onRemove: () => onRemove(m),
);
}).toList(),
),
);
}
}
class _MemberTile extends StatelessWidget {
const _MemberTile({
required this.member,
required this.isSelf,
required this.canManage,
required this.showDivider,
required this.onRoleChange,
required this.onRemove,
});
final TeamMember member;
final bool isSelf;
final bool canManage;
final bool showDivider;
final void Function(TenantRole) onRoleChange;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
final name = member.user.displayName.isNotEmpty
? member.user.displayName
: member.user.email;
final initials = name.trim().isNotEmpty
? name.trim()[0].toUpperCase()
: '?';
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: AppColors.inProgressBg,
borderRadius: BorderRadius.circular(20),
),
child: Center(
child: Text(
initials,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.inProgress,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
name,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
if (isSelf) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: AppColors.successBg,
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'Sen',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: AppColors.success,
),
),
),
],
],
),
Text(
member.user.email,
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
if (canManage) ...[
_RoleChip(
role: member.role,
onChanged: onRoleChange,
),
const SizedBox(width: 4),
IconButton(
onPressed: onRemove,
icon: const Icon(Icons.remove_circle_outline,
color: AppColors.cancelled, size: 20),
tooltip: 'Çıkar',
constraints: const BoxConstraints(),
padding: const EdgeInsets.all(6),
),
] else
_RoleBadge(role: member.role),
],
),
),
if (showDivider)
const Divider(height: 1, indent: 68, color: AppColors.border),
],
);
}
}
class _RoleChip extends StatelessWidget {
const _RoleChip({required this.role, required this.onChanged});
final TenantRole role;
final void Function(TenantRole) onChanged;
static const _selectableRoles = [
TenantRole.admin,
TenantRole.technician,
TenantRole.delivery,
TenantRole.finance,
TenantRole.doctor,
TenantRole.member,
];
@override
Widget build(BuildContext context) {
return PopupMenuButton<TenantRole>(
initialValue: role,
onSelected: onChanged,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
itemBuilder: (_) => _selectableRoles
.map((r) => PopupMenuItem(
value: r,
child: Text(r.label),
))
.toList(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: _roleBg(role),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
role.label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _roleColor(role),
),
),
const SizedBox(width: 4),
Icon(Icons.arrow_drop_down, size: 16, color: _roleColor(role)),
],
),
),
);
}
}
class _RoleBadge extends StatelessWidget {
const _RoleBadge({required this.role});
final TenantRole role;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: _roleBg(role),
borderRadius: BorderRadius.circular(8),
),
child: Text(
role.label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _roleColor(role),
),
),
);
}
}
Color _roleBg(TenantRole r) => switch (r) {
TenantRole.owner => AppColors.inProgressBg,
TenantRole.admin => AppColors.inProgressBg,
TenantRole.doctor => AppColors.successBg,
_ => AppColors.surface,
};
Color _roleColor(TenantRole r) => switch (r) {
TenantRole.owner => AppColors.inProgress,
TenantRole.admin => AppColors.inProgress,
TenantRole.doctor => AppColors.success,
_ => AppColors.textSecondary,
};
// ── Add member sheet ────────────────────────────────────────────────────────
class _AddMemberSheet extends StatefulWidget {
const _AddMemberSheet({required this.onAdd});
final Future<void> Function(
String firstName,
String lastName,
String email,
String password,
TenantRole role,
) onAdd;
@override
State<_AddMemberSheet> createState() => _AddMemberSheetState();
}
class _AddMemberSheetState extends State<_AddMemberSheet> {
final _formKey = GlobalKey<FormState>();
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
TenantRole _selectedRole = TenantRole.member;
bool _saving = false;
bool _obscurePassword = true;
static const _selectableRoles = [
TenantRole.admin,
TenantRole.technician,
TenantRole.delivery,
TenantRole.finance,
TenantRole.doctor,
TenantRole.member,
];
@override
void dispose() {
_firstNameController.dispose();
_lastNameController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
try {
await widget.onAdd(
_firstNameController.text.trim(),
_lastNameController.text.trim(),
_emailController.text.trim(),
_passwordController.text,
_selectedRole,
);
if (mounted) Navigator.pop(context);
} catch (e) {
if (mounted) {
final msg = _friendlyError(e);
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(msg)));
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
static String _friendlyError(Object e) {
final s = e.toString();
if (s.contains('email') && s.contains('unique')) {
return 'Bu e-posta adresi zaten kayıtlı.';
}
final msgMatch = RegExp(r'message: ([^,}]+)').firstMatch(s);
if (msgMatch != null) return msgMatch.group(1)!.trim();
if (s.length > 120) return 'Sunucu hatası';
return s;
}
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.of(context).viewInsets.bottom;
return Padding(
padding: EdgeInsets.fromLTRB(24, 24, 24, 24 + bottom),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'Üye Ekle',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const Spacer(),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: TextFormField(
controller: _firstNameController,
textCapitalization: TextCapitalization.words,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: 'İsim',
prefixIcon: Icon(Icons.person_outline),
),
validator: (val) {
if (val == null || val.trim().isEmpty) return 'Zorunlu';
return null;
},
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _lastNameController,
textCapitalization: TextCapitalization.words,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: 'Soyisim',
),
validator: (val) {
if (val == null || val.trim().isEmpty) return 'Zorunlu';
return null;
},
),
),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autocorrect: false,
decoration: const InputDecoration(
labelText: 'E-posta',
hintText: 'ornek@email.com',
prefixIcon: Icon(Icons.email_outlined),
),
validator: (val) {
if (val == null || val.trim().isEmpty) return 'E-posta zorunludur';
if (!val.contains('@')) return 'Geçerli bir e-posta girin';
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelText: 'Şifre',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined),
onPressed: () =>
setState(() => _obscurePassword = !_obscurePassword),
),
),
validator: (val) {
if (val == null || val.isEmpty) return 'Şifre zorunludur';
if (val.length < 8) return 'En az 8 karakter olmalı';
return null;
},
),
const SizedBox(height: 16),
DropdownButtonFormField<TenantRole>(
value: _selectedRole,
decoration: const InputDecoration(
labelText: 'Rol',
prefixIcon: Icon(Icons.badge_outlined),
),
items: _selectableRoles
.map((r) => DropdownMenuItem(
value: r,
child: Text(r.label),
))
.toList(),
onChanged: (v) {
if (v != null) setState(() => _selectedRole = v);
},
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _saving ? null : _submit,
icon: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.person_add_outlined, size: 18),
label: const Text('Üye Ekle'),
),
),
],
),
),
);
}
}
// ── Helpers ────────────────────────────────────────────────────────────────
class _EmptyCard extends StatelessWidget {
const _EmptyCard({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
),
child: Center(
child: Text(
message,
style: const TextStyle(color: AppColors.textSecondary),
),
),
);
}
}
class _ErrorView extends StatelessWidget {
const _ErrorView({required this.error, required this.onRetry});
final String error;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, color: AppColors.cancelled, size: 40),
const SizedBox(height: 12),
Text(error,
style: const TextStyle(color: AppColors.textSecondary),
textAlign: TextAlign.center),
const SizedBox(height: 12),
TextButton(onPressed: onRetry, child: const Text('Tekrar Dene')),
],
),
);
}
}
+131
View File
@@ -0,0 +1,131 @@
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'core/api/pocketbase_client.dart';
import 'core/providers/locale_provider.dart';
import 'core/router/router_provider.dart';
import 'core/services/notification_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await PocketBaseClient.init();
await NotificationService.init();
final initialLocale = await LocaleNotifier.load();
runApp(
ProviderScope(
overrides: [
localeProvider.overrideWith(
(ref) => LocaleNotifier(initialLocale),
),
],
child: const DlsApp(),
),
);
}
class DlsApp extends ConsumerWidget {
const DlsApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
final locale = ref.watch(localeProvider);
NotificationService.setRouter(router);
return ShadcnApp.router(
title: 'DLS',
debugShowCheckedModeBanner: false,
locale: locale,
supportedLocales: supportedLocales,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
theme: const ThemeData(
colorScheme: _dlsLight,
radius: 0.5,
),
darkTheme: const ThemeData.dark(
colorScheme: _dlsDark,
radius: 0.5,
),
routerConfig: router,
);
}
}
// ── DLS Light color scheme ────────────────────────────────────────────────────
const _dlsLight = ColorScheme(
brightness: Brightness.light,
background: Color(0xFFF1F5F9),
foreground: Color(0xFF0F172A),
card: Color(0xFFFFFFFF),
cardForeground: Color(0xFF0F172A),
popover: Color(0xFFFFFFFF),
popoverForeground: Color(0xFF0F172A),
primary: Color(0xFF1E3A5F),
primaryForeground: Color(0xFFFFFFFF),
secondary: Color(0xFFE2E8F0),
secondaryForeground: Color(0xFF0F172A),
muted: Color(0xFFE2E8F0),
mutedForeground: Color(0xFF64748B),
accent: Color(0xFFF8FAFC),
accentForeground: Color(0xFF0F172A),
destructive: Color(0xFFDC2626),
border: Color(0xFFE2E8F0),
input: Color(0xFFE2E8F0),
ring: Color(0xFF0369A1),
chart1: Color(0xFF1E3A5F),
chart2: Color(0xFF059669),
chart3: Color(0xFFF59E0B),
chart4: Color(0xFF0369A1),
chart5: Color(0xFFDC2626),
sidebar: Color(0xFFFFFFFF),
sidebarForeground: Color(0xFF0F172A),
sidebarPrimary: Color(0xFF1E3A5F),
sidebarPrimaryForeground: Color(0xFFFFFFFF),
sidebarAccent: Color(0xFFF1F5F9),
sidebarAccentForeground: Color(0xFF0F172A),
sidebarBorder: Color(0xFFE2E8F0),
sidebarRing: Color(0xFF0369A1),
);
// ── DLS Dark color scheme ─────────────────────────────────────────────────────
const _dlsDark = ColorScheme(
brightness: Brightness.dark,
background: Color(0xFF0F172A),
foreground: Color(0xFFF1F5F9),
card: Color(0xFF1E293B),
cardForeground: Color(0xFFF1F5F9),
popover: Color(0xFF1E293B),
popoverForeground: Color(0xFFF1F5F9),
primary: Color(0xFF93C5FD),
primaryForeground: Color(0xFF1E3A5F),
secondary: Color(0xFF273344),
secondaryForeground: Color(0xFFF1F5F9),
muted: Color(0xFF273344),
mutedForeground: Color(0xFF94A3B8),
accent: Color(0xFF273344),
accentForeground: Color(0xFFF1F5F9),
destructive: Color(0xFFFCA5A5),
border: Color(0xFF334155),
input: Color(0xFF334155),
ring: Color(0xFF7DD3FC),
chart1: Color(0xFF93C5FD),
chart2: Color(0xFF6EE7B7),
chart3: Color(0xFFFCD34D),
chart4: Color(0xFF7DD3FC),
chart5: Color(0xFFFCA5A5),
sidebar: Color(0xFF1E293B),
sidebarForeground: Color(0xFFF1F5F9),
sidebarPrimary: Color(0xFF93C5FD),
sidebarPrimaryForeground: Color(0xFF1E3A5F),
sidebarAccent: Color(0xFF273344),
sidebarAccentForeground: Color(0xFFF1F5F9),
sidebarBorder: Color(0xFF334155),
sidebarRing: Color(0xFF7DD3FC),
);
+68
View File
@@ -0,0 +1,68 @@
import 'job.dart';
enum DiscountType { percentage, fixed }
extension DiscountTypeX on DiscountType {
String get value => name;
String get label => this == DiscountType.percentage ? 'Yüzde (%)' : 'Sabit Tutar (TL)';
}
class ClinicDiscount {
const ClinicDiscount({
required this.id,
required this.labTenantId,
this.clinicTenantId,
this.clinicName,
this.prostheticType,
required this.discountType,
required this.discountValue,
this.minQuantity = 0,
required this.isActive,
this.notes,
});
final String id;
final String labTenantId;
final String? clinicTenantId; // null = tüm klinikler
final String? clinicName;
final String? prostheticType; // null = tüm ürün tipleri
final DiscountType discountType;
final double discountValue;
final int minQuantity; // 0 = minimum yok
final bool isActive;
final String? notes;
bool get appliesToAll => clinicTenantId == null || clinicTenantId!.isEmpty;
bool get appliesToAllTypes => prostheticType == null || prostheticType!.isEmpty;
String get displayValue => discountType == DiscountType.percentage
? '%${discountValue.toStringAsFixed(discountValue % 1 == 0 ? 0 : 1)}'
: '${discountValue.toStringAsFixed(2)} TL';
String get prostheticLabel {
if (appliesToAllTypes) return 'Tüm Türler';
return ProstheticType.values
.firstWhere((e) => e.value == prostheticType,
orElse: () => ProstheticType.diger)
.label;
}
factory ClinicDiscount.fromJson(Map<String, dynamic> j) {
final expand = j['expand'] as Map<String, dynamic>?;
final clinicExp = expand?['clinic_tenant_id'] as Map<String, dynamic>?;
final dt = j['discount_type'] as String? ?? 'percentage';
final pt = j['prosthetic_type'] as String?;
return ClinicDiscount(
id: j['id'] as String,
labTenantId: j['lab_tenant_id'] as String,
clinicTenantId: j['clinic_tenant_id'] as String?,
clinicName: clinicExp?['company_name'] as String?,
prostheticType: (pt == null || pt.isEmpty) ? null : pt,
discountType: dt == 'fixed' ? DiscountType.fixed : DiscountType.percentage,
discountValue: (j['discount_value'] as num?)?.toDouble() ?? 0,
minQuantity: (j['min_quantity'] as num?)?.toInt() ?? 0,
isActive: j['is_active'] as bool? ?? true,
notes: j['notes'] as String?,
);
}
}
+53
View File
@@ -0,0 +1,53 @@
enum ConnectionStatus { pending, approved, rejected }
extension ConnectionStatusX on ConnectionStatus {
String get value => name;
String get label {
switch (this) {
case ConnectionStatus.pending:
return 'Bekliyor';
case ConnectionStatus.approved:
return 'Onaylı';
case ConnectionStatus.rejected:
return 'Reddedildi';
}
}
}
class Connection {
const Connection({
required this.id,
required this.clinicTenantId,
required this.labTenantId,
required this.status,
this.clinicName,
this.labName,
this.dateCreated,
});
final String id;
final String clinicTenantId;
final String labTenantId;
final ConnectionStatus status;
final String? clinicName;
final String? labName;
final String? dateCreated;
factory Connection.fromJson(Map<String, dynamic> j) {
final expand = j['expand'] as Map<String, dynamic>?;
final clinicExp = expand?['clinic_tenant_id'] as Map<String, dynamic>?;
final labExp = expand?['lab_tenant_id'] as Map<String, dynamic>?;
return Connection(
id: j['id'] as String,
clinicTenantId: j['clinic_tenant_id'] as String,
labTenantId: j['lab_tenant_id'] as String,
status: ConnectionStatus.values.firstWhere(
(e) => e.value == j['status'],
orElse: () => ConnectionStatus.pending,
),
clinicName: clinicExp?['company_name'] as String?,
labName: labExp?['company_name'] as String?,
dateCreated: j['created'] as String?,
);
}
}
+62
View File
@@ -0,0 +1,62 @@
enum FinanceType { receivable, payable }
extension FinanceTypeX on FinanceType {
String get value => name;
String get label => this == FinanceType.receivable ? 'Alacak' : 'Borç';
}
enum FinanceStatus { pending, paid }
extension FinanceStatusX on FinanceStatus {
String get value => name;
String get label => this == FinanceStatus.pending ? 'Bekliyor' : 'Ödendi';
}
class FinanceEntry {
const FinanceEntry({
required this.id,
required this.tenantId,
required this.jobId,
required this.type,
required this.amount,
required this.currency,
required this.status,
this.paidAt,
this.counterpartyName,
this.patientCode,
this.dateCreated,
});
final String id;
final String tenantId;
final String jobId;
final FinanceType type;
final double amount;
final String currency;
final FinanceStatus status;
final String? paidAt;
final String? counterpartyName;
final String? patientCode;
final String? dateCreated;
factory FinanceEntry.fromJson(Map<String, dynamic> j) {
final expand = j['expand'] as Map<String, dynamic>?;
final jobExp = expand?['job_id'] as Map<String, dynamic>?;
String? _str(dynamic v) { final s = v as String?; return (s == null || s.isEmpty) ? null : s; }
return FinanceEntry(
id: j['id'] as String,
tenantId: j['tenant_id'] as String,
jobId: j['job_id'] as String,
type: FinanceType.values.firstWhere((e) => e.value == j['type'],
orElse: () => FinanceType.receivable),
amount: (j['amount'] as num).toDouble(),
currency: j['currency'] as String? ?? 'TRY',
status: FinanceStatus.values.firstWhere((e) => e.value == j['status'],
orElse: () => FinanceStatus.pending),
paidAt: _str(j['paid_at']),
counterpartyName: _str(j['counterparty_name']),
patientCode: jobExp?['patient_code'] as String?,
dateCreated: j['created'] as String?,
);
}
}
+312
View File
@@ -0,0 +1,312 @@
enum JobStatus { pending, inProgress, sent, delivered, cancelled }
enum JobStep {
olcu, // legacy fallback
altYapiProva, // sabit seramik/metal — alt yapı (coping)
ustYapiProva, // sabit seramik — bisküvi prova
mumProva, // hareketli protez — mum prova
dislerProva, // hareketli protez — dişler prova
dayanakProva, // implant — dayanak prova
kronProva, // implant — kron prova
cilaBitim, // son cila / bitim (her şablonda son adım)
}
enum JobLocation { atClinic, atLab }
enum ProstheticType {
metalPorselen,
zirkonyum,
implantUstuZirkonyum,
gecici,
eMax,
tamProtez,
parsiyel,
diger,
}
// ── Status ────────────────────────────────────────────────────────────────────
extension JobStatusExt on JobStatus {
String get label => switch (this) {
JobStatus.pending => 'Bekliyor',
JobStatus.inProgress => 'İşlemde',
JobStatus.sent => 'Gönderildi',
JobStatus.delivered => 'Teslim Alındı',
JobStatus.cancelled => 'İptal',
};
String get value => switch (this) {
JobStatus.pending => 'pending',
JobStatus.inProgress => 'in_progress',
JobStatus.sent => 'sent',
JobStatus.delivered => 'delivered',
JobStatus.cancelled => 'cancelled',
};
}
// ── Step ──────────────────────────────────────────────────────────────────────
extension JobStepExt on JobStep {
String get label => switch (this) {
JobStep.olcu => 'Ölçü',
JobStep.altYapiProva => 'Alt Yapı Prova',
JobStep.ustYapiProva => 'Üst Yapı Prova',
JobStep.mumProva => 'Mum Prova',
JobStep.dislerProva => 'Dişler Prova',
JobStep.dayanakProva => 'Dayanak Prova',
JobStep.kronProva => 'Kron Prova',
JobStep.cilaBitim => 'Cila / Bitim',
};
/// One-liner shown under the step on the stepper
String get description => switch (this) {
JobStep.olcu => 'İlk ölçü alındı',
JobStep.altYapiProva => 'Metal/zirkonyum coping klinik onayı',
JobStep.ustYapiProva => 'Bisküvi pişirimi sonrası klinik onayı',
JobStep.mumProva => 'Mum prova klinik onayı',
JobStep.dislerProva => 'Diş dizimi klinik onayı',
JobStep.dayanakProva => 'Dayanak klinik onayı',
JobStep.kronProva => 'Kron klinik onayı',
JobStep.cilaBitim => 'Son cila ve teslim hazırlığı',
};
String get value => switch (this) {
JobStep.olcu => 'olcu',
JobStep.altYapiProva => 'alt_yapi_prova',
JobStep.ustYapiProva => 'ust_yapi_prova',
JobStep.mumProva => 'mum_prova',
JobStep.dislerProva => 'disler_prova',
JobStep.dayanakProva => 'dayanak_prova',
JobStep.kronProva => 'kron_prova',
JobStep.cilaBitim => 'cila_bitim',
};
}
// ── Prosthetic type ───────────────────────────────────────────────────────────
extension ProstheticTypeExt on ProstheticType {
String get label => switch (this) {
ProstheticType.metalPorselen => 'Metal Porselen',
ProstheticType.zirkonyum => 'Zirkonyum',
ProstheticType.implantUstuZirkonyum => 'İmplant Üstü Zirkonyum',
ProstheticType.gecici => 'Geçici',
ProstheticType.eMax => 'E-Max',
ProstheticType.tamProtez => 'Tam Protez',
ProstheticType.parsiyel => 'Parsiyel Protez',
ProstheticType.diger => 'Diğer',
};
String get value => switch (this) {
ProstheticType.metalPorselen => 'metal_porselen',
ProstheticType.zirkonyum => 'zirkonyum',
ProstheticType.implantUstuZirkonyum => 'implant_ustu_zirkonyum',
ProstheticType.gecici => 'gecici',
ProstheticType.eMax => 'e_max',
ProstheticType.tamProtez => 'tam_protez',
ProstheticType.parsiyel => 'parsiyel',
ProstheticType.diger => 'diger',
};
}
// ── Step template ─────────────────────────────────────────────────────────────
/// Returns the ordered step list for a given prosthetic type + prova flag.
List<JobStep> jobStepTemplate(ProstheticType type, bool provaRequired) {
if (!provaRequired) return const [JobStep.cilaBitim];
return switch (type) {
// Sabit seramik: alt yapı coping + bisküvi prova + cila
ProstheticType.metalPorselen ||
ProstheticType.zirkonyum ||
ProstheticType.eMax =>
const [JobStep.altYapiProva, JobStep.ustYapiProva, JobStep.cilaBitim],
// İmplant: dayanak + kron prova + cila
ProstheticType.implantUstuZirkonyum =>
const [JobStep.dayanakProva, JobStep.kronProva, JobStep.cilaBitim],
// Hareketli protez: mum + dişler prova + cila
ProstheticType.tamProtez ||
ProstheticType.parsiyel =>
const [JobStep.mumProva, JobStep.dislerProva, JobStep.cilaBitim],
// Geçici: sadece cila (prova gereksiz)
ProstheticType.gecici =>
const [JobStep.cilaBitim],
// Diğer: tek ara prova + cila
_ =>
const [JobStep.altYapiProva, JobStep.cilaBitim],
};
}
// ── Job ───────────────────────────────────────────────────────────────────────
class Job {
const Job({
required this.id,
required this.clinicTenantId,
required this.labTenantId,
required this.patientCode,
required this.prostheticType,
required this.memberCount,
required this.status,
required this.dateCreated,
this.patientId,
this.prostheticId,
this.teeth = const [],
this.color,
this.description,
this.price,
this.currency,
this.currentStep,
this.location = JobLocation.atClinic,
this.dueDate,
this.clinicName,
this.labName,
this.attachments = const [],
this.provaRequired = true,
});
final String id;
final String clinicTenantId;
final String labTenantId;
final String? patientId;
final String patientCode;
final String? prostheticId;
final ProstheticType prostheticType;
final int memberCount;
final List<String> teeth;
final String? color;
final String? description;
final double? price;
final String? currency;
final JobStatus status;
final JobStep? currentStep;
final JobLocation location;
final DateTime? dueDate;
final DateTime dateCreated;
final List<String> attachments;
final bool provaRequired;
// Denormalized from relation joins — list views only
final String? clinicName;
final String? labName;
// ── copyWith ──────────────────────────────────────────────────────────────
Job copyWith({
JobStatus? status,
JobStep? currentStep,
JobLocation? location,
String? clinicName,
String? labName,
bool clearCurrentStep = false,
}) =>
Job(
id: id,
clinicTenantId: clinicTenantId,
labTenantId: labTenantId,
patientId: patientId,
patientCode: patientCode,
prostheticId: prostheticId,
prostheticType: prostheticType,
memberCount: memberCount,
teeth: teeth,
color: color,
description: description,
price: price,
currency: currency,
status: status ?? this.status,
currentStep: clearCurrentStep ? null : (currentStep ?? this.currentStep),
location: location ?? this.location,
dueDate: dueDate,
dateCreated: dateCreated,
attachments: attachments,
provaRequired: provaRequired,
clinicName: clinicName ?? this.clinicName,
labName: labName ?? this.labName,
);
// ── Step helpers ──────────────────────────────────────────────────────────
List<JobStep> get stepTemplate => jobStepTemplate(prostheticType, provaRequired);
bool get isLastStep =>
currentStep != null && currentStep == stepTemplate.last;
/// The next step after currentStep in this job's template, or null if done.
JobStep? get nextStep {
if (currentStep == null) return stepTemplate.firstOrNull;
final idx = stepTemplate.indexOf(currentStep!);
if (idx < 0 || idx >= stepTemplate.length - 1) return null;
return stepTemplate[idx + 1];
}
factory Job.fromJson(Map<String, dynamic> j) {
final expand = j['expand'] as Map<String, dynamic>?;
final clinicExp = expand?['clinic_tenant_id'] as Map<String, dynamic>?;
final labExp = expand?['lab_tenant_id'] as Map<String, dynamic>?;
String? str(dynamic v) {
final s = v as String?;
return (s == null || s.isEmpty) ? null : s;
}
return Job(
id: j['id'] as String,
clinicTenantId: j['clinic_tenant_id'] as String,
labTenantId: j['lab_tenant_id'] as String,
patientId: str(j['patient_id']),
patientCode: j['patient_code'] as String,
prostheticId: str(j['prosthetic_id']),
prostheticType: _parseProstheticType(j['prosthetic_type'] as String),
memberCount: (j['member_count'] as num).toInt(),
teeth: j['teeth'] is List
? (j['teeth'] as List).map((e) => e.toString()).toList()
: [],
color: str(j['color']),
description: str(j['description']),
price: (j['price'] as num?)?.toDouble(),
currency: str(j['currency']),
status: _parseStatus(j['status'] as String),
currentStep: str(j['current_step']) != null
? _parseStep(j['current_step'] as String)
: null,
location:
j['location'] == 'at_lab' ? JobLocation.atLab : JobLocation.atClinic,
dueDate: str(j['due_date']) != null
? DateTime.parse(j['due_date'] as String)
: null,
dateCreated: DateTime.parse(j['created'] as String),
clinicName: clinicExp?['company_name'] as String?,
labName: labExp?['company_name'] as String?,
attachments: j['attachments'] is List
? (j['attachments'] as List).map((e) => e.toString()).toList()
: [],
provaRequired: (j['prova_required'] as bool?) ?? true,
);
}
static JobStatus _parseStatus(String s) => switch (s) {
'in_progress' => JobStatus.inProgress,
'sent' => JobStatus.sent,
'delivered' => JobStatus.delivered,
'cancelled' => JobStatus.cancelled,
_ => JobStatus.pending,
};
static JobStep _parseStep(String s) => switch (s) {
'alt_yapi_prova' => JobStep.altYapiProva,
'ust_yapi_prova' => JobStep.ustYapiProva,
'mum_prova' => JobStep.mumProva,
'disler_prova' => JobStep.dislerProva,
'dayanak_prova' => JobStep.dayanakProva,
'kron_prova' => JobStep.kronProva,
'cila_bitim' => JobStep.cilaBitim,
_ => JobStep.olcu,
};
static ProstheticType _parseProstheticType(String s) => switch (s) {
'zirkonyum' => ProstheticType.zirkonyum,
'implant_ustu_zirkonyum'=> ProstheticType.implantUstuZirkonyum,
'gecici' => ProstheticType.gecici,
'e_max' => ProstheticType.eMax,
'tam_protez' => ProstheticType.tamProtez,
'parsiyel' => ProstheticType.parsiyel,
'diger' => ProstheticType.diger,
_ => ProstheticType.metalPorselen,
};
}
+84
View File
@@ -0,0 +1,84 @@
enum JobFileKind { scan, image, document }
extension JobFileKindExt on JobFileKind {
String get label => switch (this) {
JobFileKind.scan => 'Tarama',
JobFileKind.image => 'Görsel',
JobFileKind.document => 'Belge',
};
String get value => switch (this) {
JobFileKind.scan => 'scan',
JobFileKind.image => 'image',
JobFileKind.document => 'document',
};
static JobFileKind fromValue(String s) => switch (s) {
'image' => JobFileKind.image,
'document' => JobFileKind.document,
_ => JobFileKind.scan,
};
}
class JobFile {
const JobFile({
required this.id,
required this.jobId,
required this.clinicTenantId,
required this.labTenantId,
required this.uploadedBy,
required this.kind,
required this.fileName,
required this.name,
required this.size,
required this.createdAt,
required this.downloadUrl,
this.mimeType,
});
final String id;
final String jobId;
final String clinicTenantId;
final String labTenantId;
final String uploadedBy;
final JobFileKind kind;
final String fileName;
final String name;
final int size;
final String? mimeType;
final DateTime createdAt;
final String downloadUrl;
String get sizeLabel {
if (size < 1024) return '$size B';
if (size < 1024 * 1024) return '${(size / 1024).toStringAsFixed(1)} KB';
return '${(size / (1024 * 1024)).toStringAsFixed(2)} MB';
}
factory JobFile.fromJson(Map<String, dynamic> j, String baseUrl) {
String str(String key, [String fallback = '']) =>
(j[key] as String?) ?? fallback;
final id = str('id');
final collectionId = str('collectionId', 'job_files');
final fileName = str('file');
final url = fileName.isNotEmpty
? '$baseUrl/api/files/$collectionId/$id/$fileName'
: '';
final createdRaw = str('created');
return JobFile(
id: id,
jobId: str('job_id'),
clinicTenantId: str('clinic_tenant_id'),
labTenantId: str('lab_tenant_id'),
uploadedBy: str('uploaded_by'),
kind: JobFileKindExt.fromValue(str('kind')),
fileName: fileName,
name: str('name'),
size: (j['size'] as num?)?.toInt() ?? 0,
mimeType: j['mime_type'] as String?,
createdAt: createdRaw.isNotEmpty
? DateTime.tryParse(createdRaw) ?? DateTime(2000)
: DateTime(2000),
downloadUrl: url,
);
}
}
+49
View File
@@ -0,0 +1,49 @@
class Patient {
const Patient({
required this.id,
required this.tenantId,
required this.patientCode,
this.firstName,
this.lastName,
this.birthDate,
this.phone,
this.notes,
});
final String id;
final String tenantId;
final String patientCode;
final String? firstName;
final String? lastName;
final String? birthDate;
final String? phone;
final String? notes;
String get displayName {
final parts = [firstName, lastName].where((s) => s != null && s.isNotEmpty);
return parts.isEmpty ? patientCode : parts.join(' ');
}
factory Patient.fromJson(Map<String, dynamic> j) => Patient(
id: j['id'] as String,
tenantId: j['tenant_id'] is Map
? (j['tenant_id'] as Map)['id'] as String
: j['tenant_id'] as String,
patientCode: j['patient_code'] as String,
firstName: j['first_name'] as String?,
lastName: j['last_name'] as String?,
birthDate: j['birth_date'] as String?,
phone: j['phone'] as String?,
notes: j['notes'] as String?,
);
Map<String, dynamic> toJson() => {
'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,
};
}
+45
View File
@@ -0,0 +1,45 @@
class ProstheticProduct {
const ProstheticProduct({
required this.id,
required this.labTenantId,
required this.name,
required this.prostheticType,
this.unitPrice,
this.currency,
this.isActive = true,
this.description,
});
final String id;
final String labTenantId;
final String name;
final String prostheticType;
final double? unitPrice;
final String? currency;
final bool isActive;
final String? description;
factory ProstheticProduct.fromJson(Map<String, dynamic> j) {
String? _str(dynamic v) { final s = v as String?; return (s == null || s.isEmpty) ? null : s; }
return ProstheticProduct(
id: j['id'] as String,
labTenantId: j['lab_tenant_id'] as String,
name: j['name'] as String,
prostheticType: j['prosthetic_type'] as String,
unitPrice: (j['unit_price'] as num?)?.toDouble(),
currency: j['currency'] as String? ?? 'TRY',
isActive: j['is_active'] as bool? ?? true,
description: _str(j['description']),
);
}
Map<String, dynamic> toJson() => {
'lab_tenant_id': labTenantId,
'name': name,
'prosthetic_type': prostheticType,
if (unitPrice != null) 'unit_price': unitPrice,
'currency': currency ?? 'TRY',
'is_active': isActive,
if (description != null) 'description': description,
};
}
+145
View File
@@ -0,0 +1,145 @@
enum TenantKind { lab, clinic }
enum TenantRole {
owner,
admin,
technician, // lab: işler + ürünler
delivery, // lab: işler
finance, // lab+clinic: finans
doctor, // clinic: işler + hastalar
member, // legacy — full access
;
String get value => name;
String get label => switch (this) {
TenantRole.owner => 'Sahibi',
TenantRole.admin => 'Yönetici',
TenantRole.technician => 'Teknisyen',
TenantRole.delivery => 'Teslimat Elemanı',
TenantRole.finance => 'Finans Elemanı',
TenantRole.doctor => 'Hekim',
TenantRole.member => 'Üye',
};
}
enum TenantPlan { starter, pro, enterprise }
class Tenant {
const Tenant({
required this.id,
required this.kind,
required this.memberNumber,
required this.companyName,
this.logo,
this.defaultCurrency = 'TRY',
this.status = 'active',
this.plan,
this.maxMembers,
});
final String id;
final TenantKind kind;
final String memberNumber;
final String companyName;
final String? logo;
final String defaultCurrency;
final String status;
final TenantPlan? plan;
final int? maxMembers;
bool get isLab => kind == TenantKind.lab;
bool get isClinic => kind == TenantKind.clinic;
factory Tenant.fromJson(Map<String, dynamic> j) => Tenant(
id: j['id'] as String,
kind: j['kind'] == 'lab' ? TenantKind.lab : TenantKind.clinic,
memberNumber: (j['member_number'] as String?) ?? '',
companyName: j['company_name'] as String,
logo: j['logo'] as String?,
defaultCurrency: (j['default_currency'] as String?) ?? 'TRY',
status: (j['status'] as String?) ?? 'active',
plan: _parsePlan(j['plan'] as String?),
maxMembers: (j['max_members'] as num?)?.toInt(),
);
static TenantPlan? _parsePlan(String? p) => switch (p) {
'starter' => TenantPlan.starter,
'pro' => TenantPlan.pro,
'enterprise' => TenantPlan.enterprise,
_ => null,
};
}
class TenantMembership {
const TenantMembership({
required this.id,
required this.tenant,
required this.role,
});
final String id;
final Tenant tenant;
final TenantRole role;
// ── Access helpers ────────────────────────────────────────────────────────
bool get isOwner => role == TenantRole.owner;
bool get isAdmin => role == TenantRole.admin || role == TenantRole.owner;
bool get canManageUsers => role == TenantRole.owner || role == TenantRole.admin;
bool get canManageJobs => role != TenantRole.finance;
bool get canManageFinance => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.finance || role == TenantRole.member;
bool get canManageProducts => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.technician || role == TenantRole.member;
bool get canViewPatients => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.doctor || role == TenantRole.member;
bool get canManageConnections => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.member;
// ── Fine-grained job actions ──────────────────────────────────────────────
/// Can create new jobs (clinic side: owner/admin/doctor/member; not delivery/finance)
bool get canCreateJobs => role != TenantRole.delivery && role != TenantRole.finance;
/// Can confirm physical delivery (delivery role + supervisors)
bool get canDeliverJobs => role != TenantRole.finance;
/// Can cancel or fully manage job lifecycle (not delivery-only or finance)
bool get canCancelJobs => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.member || role == TenantRole.doctor;
/// Primary focus is delivery — restrict to delivery-relevant UI
bool get isDeliveryOnly => role == TenantRole.delivery;
// ── Nav visibility ────────────────────────────────────────────────────────
bool get showDashboard => true;
bool get showJobs => canManageJobs;
bool get showProducts => tenant.isLab && canManageProducts;
bool get showPatients => tenant.isClinic && canViewPatients;
bool get showConnections => canManageConnections;
bool get showFinance => canManageFinance;
factory TenantMembership.fromJson(Map<String, dynamic> j) {
final expand = j['expand'] as Map<String, dynamic>?;
final tenantData = expand?['tenant_id'] as Map<String, dynamic>?;
return TenantMembership(
id: j['id'] as String,
tenant: Tenant.fromJson(tenantData!),
role: parseRole(j['role'] as String),
);
}
static TenantRole parseRole(String r) => switch (r) {
'owner' => TenantRole.owner,
'admin' => TenantRole.admin,
'technician' => TenantRole.technician,
'delivery' => TenantRole.delivery,
'finance' => TenantRole.finance,
'doctor' => TenantRole.doctor,
_ => TenantRole.member,
};
String get roleLabel => switch (role) {
TenantRole.owner => 'Sahibi',
TenantRole.admin => 'Yönetici',
TenantRole.technician => 'Teknisyen',
TenantRole.delivery => 'Teslimat Elemanı',
TenantRole.finance => 'Finans Elemanı',
TenantRole.doctor => 'Hekim',
TenantRole.member => 'Üye',
};
}
+37
View File
@@ -0,0 +1,37 @@
import 'tenant.dart';
class TenantInvite {
const TenantInvite({
required this.id,
required this.tenantId,
required this.email,
required this.jobRole,
required this.token,
required this.expiresAt,
required this.status,
required this.invitedById,
});
final String id;
final String tenantId;
final String email;
final TenantRole jobRole;
final String token;
final DateTime expiresAt;
final String status; // pending | accepted | expired
final String invitedById;
bool get isPending => status == 'pending';
bool get isExpired => status == 'expired' || expiresAt.isBefore(DateTime.now());
factory TenantInvite.fromJson(Map<String, dynamic> j) => TenantInvite(
id: j['id'] as String,
tenantId: j['tenant_id'] as String,
email: j['email'] as String,
jobRole: TenantMembership.parseRole(j['job_role'] as String),
token: j['token'] as String,
expiresAt: DateTime.parse(j['expires_at'] as String),
status: j['status'] as String,
invitedById: j['invited_by'] as String,
);
}
+31
View File
@@ -0,0 +1,31 @@
class UserProfile {
const UserProfile({
required this.id,
required this.email,
this.firstName,
this.lastName,
this.preferredLanguage,
});
final String id;
final String email;
final String? firstName;
final String? lastName;
final String? preferredLanguage;
String get displayName =>
[firstName, lastName].where((s) => s != null && s.isNotEmpty).join(' ');
factory UserProfile.fromJson(Map<String, dynamic> j) => UserProfile(
id: j['id'] as String,
email: j['email'] as String,
firstName: _str(j['first_name']),
lastName: _str(j['last_name']),
preferredLanguage: _str(j['preferred_language']),
);
static String? _str(dynamic v) {
final s = v as String?;
return (s == null || s.isEmpty) ? null : s;
}
}