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;
}