Initial commit — DLS lab-app Flutter project
This commit is contained in:
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: '© 2025 Dental Lab Sistemi · KovakSoft',
|
||||
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: '© 2025 Dental Lab System · KovakSoft',
|
||||
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: '© 2025 Dental Lab System · KovakSoft',
|
||||
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: '© 2025 Dental Lab System · KovakSoft',
|
||||
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: '© 2025 Dental Lab System · KovakSoft',
|
||||
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 (د.إ)',
|
||||
);
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
});
|
||||
@@ -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'),
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (_) {}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
|
||||
/// Animated floating blob background used on auth screens.
|
||||
/// [bright] = true → white blobs (for dark/gradient backgrounds).
|
||||
/// [bright] = false → primary/accent blobs (for light backgrounds).
|
||||
class AnimatedAuthBg extends StatefulWidget {
|
||||
const AnimatedAuthBg({super.key, this.bright = false});
|
||||
final bool bright;
|
||||
|
||||
@override
|
||||
State<AnimatedAuthBg> createState() => _AnimatedAuthBgState();
|
||||
}
|
||||
|
||||
class _AnimatedAuthBgState extends State<AnimatedAuthBg>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _ctrl;
|
||||
late Animation<double> _anim;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 8),
|
||||
)..repeat(reverse: true);
|
||||
_anim = CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Color _blob(double alpha) => widget.bright
|
||||
? Colors.white.withValues(alpha: alpha * 1.5)
|
||||
: AppColors.primary.withValues(alpha: alpha);
|
||||
|
||||
Color _blobAccent(double alpha) => widget.bright
|
||||
? Colors.white.withValues(alpha: alpha * 1.2)
|
||||
: AppColors.accent.withValues(alpha: alpha);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _anim,
|
||||
builder: (_, __) {
|
||||
final t = _anim.value;
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: -80 + t * 30,
|
||||
left: -60 + t * 20,
|
||||
child: AuthBlob(size: 300, color: _blob(0.08)),
|
||||
),
|
||||
Positioned(
|
||||
top: 200 - t * 40,
|
||||
right: -100 + t * 25,
|
||||
child: AuthBlob(size: 250, color: _blobAccent(0.06)),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 100 + t * 30,
|
||||
left: 50 - t * 15,
|
||||
child: AuthBlob(size: 200, color: _blob(0.05)),
|
||||
),
|
||||
Positioned(
|
||||
bottom: -50 + t * 20,
|
||||
right: -50 + t * 10,
|
||||
child: AuthBlob(size: 280, color: _blobAccent(0.07)),
|
||||
),
|
||||
Positioned(
|
||||
top: 350 + t * 25,
|
||||
left: 80 + t * 20,
|
||||
child: AuthBlob(size: 160, color: _blob(0.04)),
|
||||
),
|
||||
Positioned(
|
||||
top: -40 - t * 10,
|
||||
left: 120 + t * 30,
|
||||
child: AuthBlob(size: 180, color: _blobAccent(0.05)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A simple solid circle used as a background blob.
|
||||
class AuthBlob extends StatelessWidget {
|
||||
const AuthBlob({super.key, required this.size, required this.color});
|
||||
|
||||
final double size;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../core/api/pocketbase_client.dart';
|
||||
import '../../core/auth/auth_repository.dart';
|
||||
|
||||
class OnboardingRepository {
|
||||
OnboardingRepository._();
|
||||
static final instance = OnboardingRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<AuthResult> createTenantAndJoin({
|
||||
required String kind,
|
||||
required String companyName,
|
||||
}) async {
|
||||
final userId = _pb.authStore.record!.id;
|
||||
|
||||
final tenant = await _pb.collection('tenants').create(body: {
|
||||
'kind': kind,
|
||||
'company_name': companyName,
|
||||
'status': 'active',
|
||||
'default_currency': 'TRY',
|
||||
});
|
||||
|
||||
await _pb.collection('tenant_members').create(body: {
|
||||
'tenant_id': tenant.id,
|
||||
'user_id': userId,
|
||||
'role': 'owner',
|
||||
});
|
||||
|
||||
return AuthRepository.instance.refreshSession();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/providers/auth_provider.dart';
|
||||
import 'onboarding_repository.dart';
|
||||
|
||||
class OnboardingScreen extends ConsumerStatefulWidget {
|
||||
const OnboardingScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<OnboardingScreen> createState() => _OnboardingScreenState();
|
||||
}
|
||||
|
||||
class _OnboardingScreenState extends ConsumerState<OnboardingScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameCtrl = TextEditingController();
|
||||
String _selectedKind = 'clinic';
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
late AnimationController _animCtrl;
|
||||
late Animation<double> _fadeAnim;
|
||||
late Animation<Offset> _slideAnim;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animCtrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 600),
|
||||
);
|
||||
_fadeAnim = CurvedAnimation(parent: _animCtrl, curve: Curves.easeOut);
|
||||
_slideAnim = Tween<Offset>(
|
||||
begin: const Offset(0, 0.08),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(parent: _animCtrl, curve: Curves.easeOutCubic));
|
||||
_animCtrl.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animCtrl.dispose();
|
||||
_nameCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _create() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final result = await OnboardingRepository.instance.createTenantAndJoin(
|
||||
kind: _selectedKind,
|
||||
companyName: _nameCtrl.text.trim(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
ref.read(authProvider.notifier).setActiveTenant(result.tenants.first);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'Hesap oluşturulamadı. Lütfen tekrar deneyin.';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF4F46E5),
|
||||
body: Stack(
|
||||
children: [
|
||||
// ── Gradient background ──────────────────────────────────────────
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF3730A3), Color(0xFF6366F1)],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Decorative circles ───────────────────────────────────────────
|
||||
Positioned(
|
||||
top: -40,
|
||||
right: -60,
|
||||
child: Container(
|
||||
width: 220,
|
||||
height: 220,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.06),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 80,
|
||||
left: -70,
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.04),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Content ──────────────────────────────────────────────────────
|
||||
Column(
|
||||
children: [
|
||||
// Header
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
child: SizedBox(
|
||||
height: size.height * 0.26,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Sign out
|
||||
Positioned(
|
||||
right: 8,
|
||||
top: 4,
|
||||
child: TextButton.icon(
|
||||
onPressed: () =>
|
||||
ref.read(authProvider.notifier).signOut(),
|
||||
icon: const Icon(Icons.logout_rounded,
|
||||
color: Colors.white70, size: 18),
|
||||
label: const Text(
|
||||
'Çıkış',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 68,
|
||||
height: 68,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.domain_add_rounded,
|
||||
size: 32,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const Text(
|
||||
'Kurumunuzu Oluşturun',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Klinik veya laboratuvar olarak kayıt olun',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.70),
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Form card
|
||||
Expanded(
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnim,
|
||||
child: SlideTransition(
|
||||
position: _slideAnim,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(32),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.15),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, -4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(28, 32, 28, 24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Kurum Türünü Seçin',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Kind cards
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _KindCard(
|
||||
icon: Icons.local_hospital_outlined,
|
||||
label: 'Klinik',
|
||||
description: 'Diş kliniği',
|
||||
value: 'clinic',
|
||||
selected: _selectedKind == 'clinic',
|
||||
onTap: () => setState(
|
||||
() => _selectedKind = 'clinic'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _KindCard(
|
||||
icon: Icons.science_outlined,
|
||||
label: 'Laboratuvar',
|
||||
description: 'Diş laboratuvarı',
|
||||
value: 'lab',
|
||||
selected: _selectedKind == 'lab',
|
||||
onTap: () =>
|
||||
setState(() => _selectedKind = 'lab'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Company name
|
||||
Text(
|
||||
'Kurum Adı',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextFormField(
|
||||
controller: _nameCtrl,
|
||||
textInputAction: TextInputAction.done,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
onFieldSubmitted: (_) => _create(),
|
||||
decoration: InputDecoration(
|
||||
labelText: _selectedKind == 'clinic'
|
||||
? 'Klinik Adı'
|
||||
: 'Laboratuvar Adı',
|
||||
prefixIcon: const Icon(
|
||||
Icons.business_outlined,
|
||||
size: 20),
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFF4F46E5), width: 2),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(
|
||||
color: cs.error, width: 1.5),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide:
|
||||
BorderSide(color: cs.error, width: 2),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 16),
|
||||
),
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) {
|
||||
return 'Kurum adı gereklidir';
|
||||
}
|
||||
if (v.trim().length < 3) {
|
||||
return 'En az 3 karakter olmalıdır';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
// Error banner
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline_rounded,
|
||||
color: cs.onErrorContainer, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
color: cs.onErrorContainer,
|
||||
fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 28),
|
||||
|
||||
FilledButton(
|
||||
onPressed: _loading ? null : _create,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
backgroundColor: const Color(0xFF4F46E5),
|
||||
),
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
height: 22,
|
||||
width: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Devam Et',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KindCard extends StatelessWidget {
|
||||
const _KindCard({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.description,
|
||||
required this.value,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String description;
|
||||
final String value;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: selected ? const Color(0xFF4F46E5) : cs.outlineVariant,
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
color: selected
|
||||
? const Color(0xFF4F46E5).withValues(alpha: 0.08)
|
||||
: cs.surfaceContainerLow,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? const Color(0xFF4F46E5).withValues(alpha: 0.12)
|
||||
: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 26,
|
||||
color: selected
|
||||
? const Color(0xFF4F46E5)
|
||||
: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
color: selected ? const Color(0xFF4F46E5) : cs.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
description,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,888 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/l10n/app_strings.dart';
|
||||
import '../../core/providers/auth_provider.dart';
|
||||
import '../../core/providers/locale_provider.dart';
|
||||
import '../../core/router/app_router.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/tooth_logo.dart';
|
||||
|
||||
class SignInScreen extends ConsumerStatefulWidget {
|
||||
const SignInScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SignInScreen> createState() => _SignInScreenState();
|
||||
}
|
||||
|
||||
class _SignInScreenState extends ConsumerState<SignInScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailCtrl = TextEditingController();
|
||||
final _passCtrl = TextEditingController();
|
||||
bool _obscure = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailCtrl.dispose();
|
||||
_passCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
await ref
|
||||
.read(authProvider.notifier)
|
||||
.signIn(_emailCtrl.text.trim(), _passCtrl.text);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = ref.watch(authProvider);
|
||||
final s = ref.watch(stringsProvider);
|
||||
final locale = ref.watch(localeProvider);
|
||||
final isDesktop = MediaQuery.sizeOf(context).width > 800;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: isDesktop
|
||||
? _buildDesktop(context, auth, s, locale)
|
||||
: _buildMobile(context, auth, s, locale),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Mobile ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _buildMobile(
|
||||
BuildContext context, dynamic auth, AppStrings s, Locale locale) {
|
||||
return Stack(
|
||||
children: [
|
||||
SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 56),
|
||||
|
||||
// Logo mark
|
||||
Center(
|
||||
child: Container(
|
||||
width: 68,
|
||||
height: 68,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF0B1D35), Color(0xFF1A5C8A)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF0B1D35).withValues(alpha: 0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child:
|
||||
const Center(child: ToothLogo(size: 34, color: Colors.white)),
|
||||
),
|
||||
).animate().fadeIn(duration: 400.ms).scale(begin: const Offset(0.8, 0.8)),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Center(
|
||||
child: Text(
|
||||
s.signInWelcome,
|
||||
style: const TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
).animate(delay: 60.ms).fadeIn(duration: 400.ms).slideY(begin: 0.1),
|
||||
const SizedBox(height: 6),
|
||||
Center(
|
||||
child: Text(
|
||||
s.signInSubtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 14, color: AppColors.textSecondary),
|
||||
),
|
||||
).animate(delay: 100.ms).fadeIn(duration: 400.ms),
|
||||
const SizedBox(height: 36),
|
||||
|
||||
_buildFormFields(auth, s),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
_buildSignUpLink(context, s),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top + 12,
|
||||
right: 12,
|
||||
child: _LanguageButton(locale: locale, s: s, ref: ref),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Desktop ────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _buildDesktop(
|
||||
BuildContext context, dynamic auth, AppStrings s, Locale locale) {
|
||||
return Row(
|
||||
children: [
|
||||
// LEFT PANEL
|
||||
Expanded(
|
||||
flex: 55,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
stops: [0.0, 0.55, 1.0],
|
||||
colors: [
|
||||
Color(0xFF080F1E),
|
||||
Color(0xFF0D2D58),
|
||||
Color(0xFF0E4A82),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Positioned(top: -140, left: -140, child: _Ring(size: 520, opacity: 0.06)),
|
||||
const Positioned(bottom: -100, right: -100, child: _Ring(size: 400, opacity: 0.05)),
|
||||
const Positioned(top: 160, right: 60, child: _Ring(size: 100, opacity: 0.09)),
|
||||
const Positioned(bottom: 220, left: 60, child: _Ring(size: 70, opacity: 0.07)),
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 64, vertical: 52),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
child: ToothLogo(size: 20, color: Colors.white)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'DLS',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
).animate().fadeIn(duration: 500.ms),
|
||||
const Spacer(),
|
||||
Text(
|
||||
s.signInHeadline,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 46,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.1,
|
||||
letterSpacing: -1.0,
|
||||
),
|
||||
)
|
||||
.animate(delay: 100.ms)
|
||||
.fadeIn(duration: 500.ms)
|
||||
.slideY(begin: 0.1, end: 0),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
s.signInTagline,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
fontSize: 16,
|
||||
height: 1.6,
|
||||
),
|
||||
).animate(delay: 160.ms).fadeIn(duration: 500.ms),
|
||||
const SizedBox(height: 44),
|
||||
const _DashboardPreviewCard()
|
||||
.animate(delay: 220.ms)
|
||||
.fadeIn(duration: 600.ms)
|
||||
.slideY(begin: 0.12, end: 0),
|
||||
const Spacer(),
|
||||
Text(
|
||||
s.footerCopyright,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
fontSize: 12,
|
||||
),
|
||||
).animate(delay: 300.ms).fadeIn(duration: 500.ms),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// RIGHT PANEL
|
||||
Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 460,
|
||||
color: Colors.white,
|
||||
child: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints:
|
||||
BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: IntrinsicHeight(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 52, vertical: 40),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Color(0xFF0B1D35),
|
||||
Color(0xFF1A5C8A)
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Center(
|
||||
child: ToothLogo(
|
||||
size: 24, color: Colors.white)),
|
||||
).animate().fadeIn(duration: 400.ms),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
s.signInWelcome,
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
)
|
||||
.animate(delay: 60.ms)
|
||||
.fadeIn(duration: 400.ms)
|
||||
.slideY(begin: 0.08, end: 0),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
s.signInSubtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
).animate(delay: 100.ms).fadeIn(duration: 400.ms),
|
||||
const SizedBox(height: 40),
|
||||
_buildFormFields(auth, s)
|
||||
.animate(delay: 140.ms)
|
||||
.fadeIn(duration: 400.ms)
|
||||
.slideY(begin: 0.08, end: 0),
|
||||
const SizedBox(height: 28),
|
||||
_buildSignUpLink(context, s)
|
||||
.animate(delay: 200.ms)
|
||||
.fadeIn(duration: 400.ms),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top + 16,
|
||||
right: 16,
|
||||
child: _LanguageButton(locale: locale, s: s, ref: ref),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Form fields (shared) ────────────────────────────────────────────────────
|
||||
|
||||
Widget _buildFormFields(dynamic auth, AppStrings s) {
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Field(
|
||||
controller: _emailCtrl,
|
||||
label: s.emailAddress,
|
||||
icon: Icons.email_outlined,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? s.emailRequired : null,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
_Field(
|
||||
controller: _passCtrl,
|
||||
label: s.password,
|
||||
icon: Icons.lock_outline_rounded,
|
||||
obscureText: _obscure,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscure
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
size: 20,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
onPressed: () => setState(() => _obscure = !_obscure),
|
||||
),
|
||||
validator: (v) =>
|
||||
(v == null || v.isEmpty) ? s.passwordRequired : null,
|
||||
),
|
||||
|
||||
if (auth.error != null) ...[
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEF2F2),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: AppColors.cancelled.withValues(alpha: 0.25)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline_rounded,
|
||||
color: AppColors.cancelled, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
auth.error!,
|
||||
style: const TextStyle(
|
||||
color: AppColors.cancelled, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF0B1D35), Color(0xFF1A5C8A)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF0B1D35).withValues(alpha: 0.35),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: FilledButton(
|
||||
onPressed: auth.isLoading ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: Colors.white,
|
||||
disabledForegroundColor: Colors.white.withValues(alpha: 0.5),
|
||||
disabledBackgroundColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: auth.isLoading
|
||||
? const SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5, color: Colors.white),
|
||||
)
|
||||
: Text(
|
||||
s.signIn,
|
||||
style: const TextStyle(
|
||||
fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sign-up link ───────────────────────────────────────────────────────────
|
||||
|
||||
Widget _buildSignUpLink(BuildContext context, AppStrings s) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
s.noAccount,
|
||||
style:
|
||||
const TextStyle(color: AppColors.textSecondary, fontSize: 14),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.go(routeSignUp),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF0D4C85),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
child: Text(
|
||||
s.signUp,
|
||||
style:
|
||||
const TextStyle(fontWeight: FontWeight.w700, fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Language button ───────────────────────────────────────────────────────────
|
||||
|
||||
class _LanguageButton extends StatelessWidget {
|
||||
const _LanguageButton(
|
||||
{required this.locale, required this.s, required this.ref});
|
||||
final Locale locale;
|
||||
final AppStrings s;
|
||||
final WidgetRef ref;
|
||||
|
||||
static const _flags = {
|
||||
'tr': '🇹🇷',
|
||||
'en': '🇬🇧',
|
||||
'ru': '🇷🇺',
|
||||
'ar': '🇸🇦',
|
||||
'de': '🇩🇪',
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final flag = _flags[locale.languageCode] ?? '🌐';
|
||||
return GestureDetector(
|
||||
onTap: () => _showPicker(context),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.06),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(flag, style: const TextStyle(fontSize: 15)),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.expand_more_rounded,
|
||||
size: 14, color: AppColors.textSecondary),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPicker(BuildContext context) {
|
||||
final options = [
|
||||
('tr', '🇹🇷', s.languageTurkish),
|
||||
('en', '🇬🇧', s.languageEnglish),
|
||||
('ru', '🇷🇺', s.languageRussian),
|
||||
('ar', '🇸🇦', s.languageArabic),
|
||||
('de', '🇩🇪', s.languageGerman),
|
||||
];
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.border,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
s.languageSelection,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
for (final (code, flag, label) in options)
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
leading: Text(flag, style: const TextStyle(fontSize: 24)),
|
||||
title: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
trailing: locale.languageCode == code
|
||||
? const Icon(Icons.check_circle_rounded,
|
||||
color: AppColors.accent)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(localeProvider.notifier).setLocale(Locale(code));
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
SizedBox(height: MediaQuery.paddingOf(context).bottom + 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Decorative ring ───────────────────────────────────────────────────────────
|
||||
|
||||
class _Ring extends StatelessWidget {
|
||||
const _Ring({required this.size, required this.opacity});
|
||||
final double size;
|
||||
final double opacity;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: opacity),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dashboard preview card (glassmorphism) ────────────────────────────────────
|
||||
|
||||
class _DashboardPreviewCard extends StatelessWidget {
|
||||
const _DashboardPreviewCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
|
||||
child: Container(
|
||||
width: 340,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.bar_chart_rounded,
|
||||
color: Colors.white,
|
||||
size: 15,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Bugünkü Durum',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'Canlı',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
const Row(
|
||||
children: [
|
||||
_StatChip(value: '24', label: 'Aktif', color: Color(0xFF60A5FA)),
|
||||
SizedBox(width: 8),
|
||||
_StatChip(
|
||||
value: '8', label: 'Bekliyor', color: Color(0xFFFBBF24)),
|
||||
SizedBox(width: 8),
|
||||
_StatChip(
|
||||
value: '142', label: 'Bu ay', color: Color(0xFF34D399)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
const _PreviewBar(
|
||||
label: 'Zirkon', value: 0.76, color: Color(0xFF60A5FA)),
|
||||
const SizedBox(height: 10),
|
||||
const _PreviewBar(
|
||||
label: 'Metal alt.', value: 0.48, color: Color(0xFFFBBF24)),
|
||||
const SizedBox(height: 10),
|
||||
const _PreviewBar(
|
||||
label: 'Porselen', value: 0.62, color: Color(0xFF34D399)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatChip extends StatelessWidget {
|
||||
const _StatChip({
|
||||
required this.value,
|
||||
required this.label,
|
||||
required this.color,
|
||||
});
|
||||
final String value;
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: color.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.55),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PreviewBar extends StatelessWidget {
|
||||
const _PreviewBar({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.color,
|
||||
});
|
||||
final String label;
|
||||
final double value;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.65),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${(value * 100).toInt()}%',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.65),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
LayoutBuilder(
|
||||
builder: (_, constraints) => Stack(
|
||||
children: [
|
||||
Container(
|
||||
height: 5,
|
||||
width: constraints.maxWidth,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 5,
|
||||
width: constraints.maxWidth * value,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Form field ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _Field extends StatelessWidget {
|
||||
const _Field({
|
||||
required this.controller,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
this.keyboardType,
|
||||
this.textInputAction,
|
||||
this.obscureText = false,
|
||||
this.suffixIcon,
|
||||
this.onFieldSubmitted,
|
||||
this.validator,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final TextInputType? keyboardType;
|
||||
final TextInputAction? textInputAction;
|
||||
final bool obscureText;
|
||||
final Widget? suffixIcon;
|
||||
final ValueChanged<String>? onFieldSubmitted;
|
||||
final FormFieldValidator<String>? validator;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
textInputAction: textInputAction,
|
||||
obscureText: obscureText,
|
||||
onFieldSubmitted: onFieldSubmitted,
|
||||
validator: validator,
|
||||
style: const TextStyle(fontSize: 15, color: AppColors.textPrimary),
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
prefixIcon: Icon(icon, size: 20, color: AppColors.textSecondary),
|
||||
suffixIcon: suffixIcon,
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF8FAFC),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: Color(0xFF0D4C85), width: 2),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide:
|
||||
const BorderSide(color: AppColors.cancelled, width: 1.5),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.cancelled, width: 2),
|
||||
),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
labelStyle: const TextStyle(
|
||||
color: AppColors.textSecondary, fontSize: 14),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/providers/auth_provider.dart';
|
||||
import '../../core/router/app_router.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/tooth_logo.dart';
|
||||
import 'auth_widgets.dart';
|
||||
|
||||
class SignUpScreen extends ConsumerStatefulWidget {
|
||||
const SignUpScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SignUpScreen> createState() => _SignUpScreenState();
|
||||
}
|
||||
|
||||
class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _firstNameCtrl = TextEditingController();
|
||||
final _lastNameCtrl = TextEditingController();
|
||||
final _emailCtrl = TextEditingController();
|
||||
final _passCtrl = TextEditingController();
|
||||
final _confirmPassCtrl = TextEditingController();
|
||||
bool _obscure = true;
|
||||
bool _obscureConfirm = true;
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_firstNameCtrl.dispose();
|
||||
_lastNameCtrl.dispose();
|
||||
_emailCtrl.dispose();
|
||||
_passCtrl.dispose();
|
||||
_confirmPassCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await ref.read(authProvider.notifier).register(
|
||||
email: _emailCtrl.text.trim(),
|
||||
password: _passCtrl.text,
|
||||
firstName: _firstNameCtrl.text.trim(),
|
||||
lastName: _lastNameCtrl.text.trim(),
|
||||
);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = _parseError(e.toString());
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _parseError(String msg) {
|
||||
if (msg.contains('already') || msg.contains('unique') || msg.contains('UNIQUE')) {
|
||||
return 'Bu e-posta adresi zaten kayıtlı.';
|
||||
}
|
||||
if (msg.contains('403') || msg.contains('Forbidden')) {
|
||||
return 'Kayıt şu anda kapalı. Lütfen yönetici ile iletişime geçin.';
|
||||
}
|
||||
return 'Kayıt olunamadı. Lütfen tekrar deneyin.';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDesktop = MediaQuery.sizeOf(context).width > 800;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: isDesktop ? _buildDesktop(context) : _buildMobile(context),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Mobile layout ──────────────────────────────────────────────────────────
|
||||
|
||||
Widget _buildMobile(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: _buildForm(context, isMobile: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Desktop layout ─────────────────────────────────────────────────────────
|
||||
|
||||
Widget _buildDesktop(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
// LEFT PANEL — solid gradient + white animated blobs on top
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.primary, Color(0xFF1A5C8A)],
|
||||
),
|
||||
),
|
||||
),
|
||||
const AnimatedAuthBg(bright: true),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 56),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: const Center(child: ToothLogo(size: 38, color: Colors.white)),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'DLS',
|
||||
style: TextStyle(
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
letterSpacing: 2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Dental Lab Sistemi',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
const _FeatureBullet(
|
||||
icon: Icons.dashboard_rounded,
|
||||
text: 'İş takibi tek ekranda',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const _FeatureBullet(
|
||||
icon: Icons.link_rounded,
|
||||
text: 'Klinik-lab bağlantısı',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const _FeatureBullet(
|
||||
icon: Icons.bolt_rounded,
|
||||
text: 'Gerçek zamanlı durum',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// RIGHT PANEL — light gray so white card stands out
|
||||
Container(
|
||||
width: 480,
|
||||
color: AppColors.background,
|
||||
child: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: IntrinsicHeight(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [_buildForm(context, isMobile: false)],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Shared form content ────────────────────────────────────────────────────
|
||||
|
||||
Widget _buildForm(BuildContext context, {required bool isMobile}) {
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (isMobile) const SizedBox(height: 48),
|
||||
|
||||
// ── Back button + branding (mobile only) ───────────────────────
|
||||
if (isMobile) ...[
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => context.go(routeSignIn),
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 20),
|
||||
style: IconButton.styleFrom(
|
||||
foregroundColor: AppColors.textPrimary,
|
||||
backgroundColor: AppColors.surface,
|
||||
padding: const EdgeInsets.all(10),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
).animate().fadeIn(duration: 300.ms),
|
||||
|
||||
const SizedBox(height: 28),
|
||||
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.primary, AppColors.accent],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.accent.withValues(alpha: 0.3),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.person_add_alt_1_rounded,
|
||||
size: 32,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Hesap Oluştur',
|
||||
style: TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'DLS ağına katılın',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
).animate().fadeIn(duration: 400.ms).slideY(begin: -0.08, end: 0),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
|
||||
// Desktop back button (outside card)
|
||||
if (!isMobile) ...[
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => context.go(routeSignIn),
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 18),
|
||||
style: IconButton.styleFrom(
|
||||
foregroundColor: AppColors.textPrimary,
|
||||
backgroundColor: AppColors.surface,
|
||||
padding: const EdgeInsets.all(8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
).animate().fadeIn(duration: 300.ms),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// ── Form card ──────────────────────────────────────────────────
|
||||
Container(
|
||||
padding: EdgeInsets.all(isMobile ? 24 : 32),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: isMobile ? 0.05 : 0.09),
|
||||
blurRadius: isMobile ? 16 : 28,
|
||||
spreadRadius: 0,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Heading inside card on desktop
|
||||
if (!isMobile) ...[
|
||||
const Text(
|
||||
'Hesap Oluştur',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
).animate().fadeIn(duration: 400.ms).slideY(begin: -0.08, end: 0),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'DLS ağına katılın',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.textSecondary),
|
||||
).animate(delay: 40.ms).fadeIn(duration: 400.ms),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
// Ad / Soyad satırı
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Field(
|
||||
controller: _firstNameCtrl,
|
||||
label: 'Ad',
|
||||
icon: Icons.badge_outlined,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? 'Gerekli' : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _Field(
|
||||
controller: _lastNameCtrl,
|
||||
label: 'Soyad',
|
||||
icon: Icons.badge_outlined,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? 'Gerekli' : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_Field(
|
||||
controller: _emailCtrl,
|
||||
label: 'E-posta',
|
||||
icon: Icons.email_outlined,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return 'E-posta gereklidir';
|
||||
if (!v.contains('@')) return 'Geçerli bir e-posta girin';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_Field(
|
||||
controller: _passCtrl,
|
||||
label: 'Şifre',
|
||||
icon: Icons.lock_outline_rounded,
|
||||
obscureText: _obscure,
|
||||
textInputAction: TextInputAction.next,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined,
|
||||
size: 20,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
onPressed: () => setState(() => _obscure = !_obscure),
|
||||
),
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return 'Şifre gereklidir';
|
||||
if (v.length < 8) return 'En az 8 karakter olmalıdır';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_Field(
|
||||
controller: _confirmPassCtrl,
|
||||
label: 'Şifre Tekrar',
|
||||
icon: Icons.lock_outline_rounded,
|
||||
obscureText: _obscureConfirm,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscureConfirm
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
size: 20,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
onPressed: () => setState(() => _obscureConfirm = !_obscureConfirm),
|
||||
),
|
||||
validator: (v) =>
|
||||
(v != _passCtrl.text) ? 'Şifreler eşleşmiyor' : null,
|
||||
),
|
||||
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: AppColors.cancelled.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline_rounded,
|
||||
color: AppColors.cancelled, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: const TextStyle(
|
||||
color: AppColors.cancelled, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
FilledButton(
|
||||
onPressed: _loading ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5, color: Colors.white),
|
||||
)
|
||||
: const Text(
|
||||
'Kayıt Ol',
|
||||
style:
|
||||
TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
).animate(delay: 100.ms).fadeIn(duration: 400.ms).slideY(begin: 0.1, end: 0),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Zaten hesabın var mı?',
|
||||
style: TextStyle(color: AppColors.textSecondary, fontSize: 14),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.go(routeSignIn),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.accent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
child: const Text(
|
||||
'Giriş Yap',
|
||||
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
).animate(delay: 200.ms).fadeIn(duration: 400.ms),
|
||||
|
||||
SizedBox(height: isMobile ? 32 : 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feature bullet (desktop left panel) ──────────────────────────────────────
|
||||
|
||||
class _FeatureBullet extends StatelessWidget {
|
||||
const _FeatureBullet({required this.icon, required this.text});
|
||||
final IconData icon;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(icon, size: 18, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Form field ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _Field extends StatelessWidget {
|
||||
const _Field({
|
||||
required this.controller,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
this.keyboardType,
|
||||
this.textCapitalization = TextCapitalization.none,
|
||||
this.textInputAction,
|
||||
this.obscureText = false,
|
||||
this.suffixIcon,
|
||||
this.onFieldSubmitted,
|
||||
this.validator,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final TextInputType? keyboardType;
|
||||
final TextCapitalization textCapitalization;
|
||||
final TextInputAction? textInputAction;
|
||||
final bool obscureText;
|
||||
final Widget? suffixIcon;
|
||||
final ValueChanged<String>? onFieldSubmitted;
|
||||
final FormFieldValidator<String>? validator;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
textCapitalization: textCapitalization,
|
||||
textInputAction: textInputAction,
|
||||
obscureText: obscureText,
|
||||
onFieldSubmitted: onFieldSubmitted,
|
||||
validator: validator,
|
||||
style: const TextStyle(fontSize: 15, color: AppColors.textPrimary),
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
prefixIcon: Icon(icon, size: 20, color: AppColors.textSecondary),
|
||||
suffixIcon: suffixIcon,
|
||||
filled: true,
|
||||
fillColor: AppColors.background,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.accent, width: 2),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.cancelled, width: 1.5),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.cancelled, width: 2),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
labelStyle: const TextStyle(color: AppColors.textSecondary, fontSize: 14),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../models/connection.dart';
|
||||
|
||||
class ClinicConnectionsRepository {
|
||||
ClinicConnectionsRepository._();
|
||||
static final instance = ClinicConnectionsRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<List<Connection>> listConnections(String clinicTenantId) async {
|
||||
final result = await _pb.collection('connections').getList(
|
||||
filter: 'clinic_tenant_id = "$clinicTenantId"',
|
||||
expand: 'lab_tenant_id,clinic_tenant_id',
|
||||
perPage: 100,
|
||||
);
|
||||
return (result.items.map((r) => Connection.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? '')));
|
||||
}
|
||||
|
||||
Future<Connection> requestConnection({
|
||||
required String clinicTenantId,
|
||||
required String labTenantId,
|
||||
}) async {
|
||||
final record = await _pb.collection('connections').create(body: {
|
||||
'clinic_tenant_id': clinicTenantId,
|
||||
'lab_tenant_id': labTenantId,
|
||||
'status': 'pending',
|
||||
});
|
||||
return Connection.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> searchLabs(String query) async {
|
||||
final result = await _pb.collection('tenants').getList(
|
||||
filter: 'kind = "lab" && company_name ~ "$query"',
|
||||
perPage: 20,
|
||||
);
|
||||
return result.items.map((r) => r.toJson()).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/connection.dart';
|
||||
import 'clinic_connections_repository.dart';
|
||||
|
||||
class ClinicConnectionsScreen extends ConsumerStatefulWidget {
|
||||
const ClinicConnectionsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ClinicConnectionsScreen> createState() =>
|
||||
_ClinicConnectionsScreenState();
|
||||
}
|
||||
|
||||
class _ClinicConnectionsScreenState
|
||||
extends ConsumerState<ClinicConnectionsScreen> {
|
||||
late Future<List<Connection>> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_future = ClinicConnectionsRepository.instance
|
||||
.listConnections(tenantId);
|
||||
});
|
||||
}
|
||||
|
||||
void _showSearchDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => _LabSearchDialog(
|
||||
onRequested: (labId, labName) async {
|
||||
Navigator.of(ctx).pop();
|
||||
final tenantId =
|
||||
ref.read(authProvider).activeTenant!.tenant.id;
|
||||
try {
|
||||
await ClinicConnectionsRepository.instance.requestConnection(
|
||||
clinicTenantId: tenantId,
|
||||
labTenantId: labId,
|
||||
);
|
||||
_load();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'$labName\'a bağlantı talebi gönderildi.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Bağlantılar'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_link),
|
||||
tooltip: 'Laboratuvar Bul',
|
||||
onPressed: _showSearchDialog,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
color: AppColors.accent,
|
||||
onRefresh: () async => _load(),
|
||||
child: FutureBuilder<List<Connection>>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent));
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
child: const Icon(Icons.wifi_off_rounded,
|
||||
color: AppColors.cancelled, size: 30),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Hata: ${snap.error}',
|
||||
style:
|
||||
const TextStyle(color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('Tekrar Dene'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final connections = snap.data!;
|
||||
if (connections.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(20)),
|
||||
child: const Icon(Icons.link_off,
|
||||
color: AppColors.inProgress, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Henüz bağlantı yok',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: _showSearchDialog,
|
||||
icon: const Icon(Icons.search),
|
||||
label: const Text('Laboratuvar Bul'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
itemCount: connections.length,
|
||||
itemBuilder: (context, index) {
|
||||
final conn = connections[index];
|
||||
final statusColor = _statusColor(conn.status);
|
||||
final statusBg = _statusBg(conn.status);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2))
|
||||
]),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: statusBg,
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(Icons.science_outlined,
|
||||
color: statusColor, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
conn.labName ?? 'Laboratuvar',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
if (conn.dateCreated != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_formatDate(conn.dateCreated!),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
_StatusChip(status: conn.status),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _showSearchDialog,
|
||||
backgroundColor: AppColors.accent,
|
||||
foregroundColor: Colors.white,
|
||||
icon: const Icon(Icons.search),
|
||||
label: const Text('Laboratuvar Bul'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _statusColor(ConnectionStatus s) {
|
||||
switch (s) {
|
||||
case ConnectionStatus.pending:
|
||||
return AppColors.pending;
|
||||
case ConnectionStatus.approved:
|
||||
return AppColors.success;
|
||||
case ConnectionStatus.rejected:
|
||||
return AppColors.cancelled;
|
||||
}
|
||||
}
|
||||
|
||||
Color _statusBg(ConnectionStatus s) {
|
||||
switch (s) {
|
||||
case ConnectionStatus.pending:
|
||||
return AppColors.pendingBg;
|
||||
case ConnectionStatus.approved:
|
||||
return AppColors.successBg;
|
||||
case ConnectionStatus.rejected:
|
||||
return AppColors.cancelledBg;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDate(String dateStr) {
|
||||
try {
|
||||
final d = DateTime.parse(dateStr);
|
||||
return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
|
||||
} catch (_) {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
const _StatusChip({required this.status});
|
||||
final ConnectionStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = _color(status);
|
||||
final bg = _bg(status);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
status.label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _color(ConnectionStatus s) {
|
||||
switch (s) {
|
||||
case ConnectionStatus.pending:
|
||||
return AppColors.pending;
|
||||
case ConnectionStatus.approved:
|
||||
return AppColors.success;
|
||||
case ConnectionStatus.rejected:
|
||||
return AppColors.cancelled;
|
||||
}
|
||||
}
|
||||
|
||||
Color _bg(ConnectionStatus s) {
|
||||
switch (s) {
|
||||
case ConnectionStatus.pending:
|
||||
return AppColors.pendingBg;
|
||||
case ConnectionStatus.approved:
|
||||
return AppColors.successBg;
|
||||
case ConnectionStatus.rejected:
|
||||
return AppColors.cancelledBg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _LabSearchDialog extends StatefulWidget {
|
||||
const _LabSearchDialog({required this.onRequested});
|
||||
final void Function(String labId, String labName) onRequested;
|
||||
|
||||
@override
|
||||
State<_LabSearchDialog> createState() => _LabSearchDialogState();
|
||||
}
|
||||
|
||||
class _LabSearchDialogState extends State<_LabSearchDialog> {
|
||||
final _searchController = TextEditingController();
|
||||
List<Map<String, dynamic>> _results = [];
|
||||
bool _isLoading = false;
|
||||
bool _searched = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _search() async {
|
||||
final query = _searchController.text.trim();
|
||||
if (query.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_searched = true;
|
||||
});
|
||||
try {
|
||||
final results =
|
||||
await ClinicConnectionsRepository.instance.searchLabs(query);
|
||||
setState(() {
|
||||
_results = results;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _isLoading = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Laboratuvar Bul'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Lab adı ile arayın...',
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 8),
|
||||
),
|
||||
onSubmitted: (_) => _search(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: _search,
|
||||
child: const Text('Ara'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: CircularProgressIndicator(color: AppColors.accent),
|
||||
)
|
||||
else if (_searched && _results.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('Sonuç bulunamadı',
|
||||
style: TextStyle(color: AppColors.textSecondary)),
|
||||
)
|
||||
else if (_results.isNotEmpty)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 240),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: _results.length,
|
||||
itemBuilder: (context, index) {
|
||||
final lab = _results[index];
|
||||
final name =
|
||||
lab['company_name'] as String? ?? 'Lab';
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
child: const Icon(Icons.science_outlined,
|
||||
color: AppColors.inProgress, size: 18),
|
||||
),
|
||||
title: Text(name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary)),
|
||||
subtitle: lab['member_number'] != null
|
||||
? Text('No: ${lab['member_number']}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary))
|
||||
: null,
|
||||
onTap: () =>
|
||||
widget.onRequested(lab['id'] as String, name),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('İptal'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../models/finance_entry.dart';
|
||||
|
||||
class ClinicFinanceRepository {
|
||||
ClinicFinanceRepository._();
|
||||
static final instance = ClinicFinanceRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<List<FinanceEntry>> listEntries(
|
||||
String tenantId, {
|
||||
String? status,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) async {
|
||||
final filterParts = ['tenant_id = "$tenantId"', 'type = "payable"'];
|
||||
if (status != null) filterParts.add('status = "$status"');
|
||||
|
||||
final result = await _pb.collection('finance_entries').getList(
|
||||
page: page,
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
expand: 'job_id',
|
||||
);
|
||||
return (result.items.map((r) => FinanceEntry.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? '')));
|
||||
}
|
||||
|
||||
Future<Map<String, double>> summary(String tenantId) async {
|
||||
final all = await listEntries(tenantId, limit: 200);
|
||||
double pending = 0, paid = 0;
|
||||
for (final e in all) {
|
||||
if (e.status == FinanceStatus.pending) {
|
||||
pending += e.amount;
|
||||
} else {
|
||||
paid += e.amount;
|
||||
}
|
||||
}
|
||||
return {'pending': pending, 'paid': paid};
|
||||
}
|
||||
|
||||
Future<void> markPaid(String entryId) async {
|
||||
await _pb.collection('finance_entries').update(entryId, body: {
|
||||
'status': 'paid',
|
||||
'paid_at': DateTime.now().toIso8601String(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/providers/locale_provider.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/utils/currency_formatter.dart';
|
||||
import '../../../core/widgets/gradient_app_bar.dart';
|
||||
import '../../../core/widgets/pill_tabs.dart';
|
||||
import '../../../models/finance_entry.dart';
|
||||
import 'clinic_finance_repository.dart';
|
||||
|
||||
enum _FinanceSort { newestFirst, byAmountDesc, byAmountAsc }
|
||||
|
||||
class ClinicFinanceScreen extends ConsumerStatefulWidget {
|
||||
const ClinicFinanceScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ClinicFinanceScreen> createState() =>
|
||||
_ClinicFinanceScreenState();
|
||||
}
|
||||
|
||||
class _ClinicFinanceScreenState extends ConsumerState<ClinicFinanceScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
late Future<Map<String, double>> _summaryFuture;
|
||||
_FinanceSort _sort = _FinanceSort.newestFirst;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_tabController.addListener(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
_loadSummary();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _loadSummary() {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_summaryFuture =
|
||||
ClinicFinanceRepository.instance.summary(tenantId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showSortOptions() async {
|
||||
final s = ref.read(stringsProvider);
|
||||
final result = await showSortSheet(
|
||||
context,
|
||||
title: s.sort,
|
||||
options: [s.sortNewest, s.sortAmountDesc, s.sortAmountAsc],
|
||||
current: _sort.index,
|
||||
);
|
||||
if (result != null) {
|
||||
setState(() => _sort = _FinanceSort.values[result]);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isSortActive = _sort != _FinanceSort.newestFirst;
|
||||
final s = ref.watch(stringsProvider);
|
||||
final currencyCode =
|
||||
ref.watch(authProvider).activeTenant?.tenant.defaultCurrency ?? 'TRY';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: GradientAppBar(
|
||||
title: s.finance,
|
||||
category: s.clinicCategory,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _showSortOptions,
|
||||
tooltip: 'Sırala',
|
||||
icon: Badge(
|
||||
isLabelVisible: isSortActive,
|
||||
smallSize: 8,
|
||||
backgroundColor: AppColors.accent,
|
||||
child: const Icon(Icons.sort_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
FutureBuilder<Map<String, double>>(
|
||||
future: _summaryFuture,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const LinearProgressIndicator(
|
||||
color: AppColors.accent);
|
||||
}
|
||||
final summary = snap.data ?? {'pending': 0.0, 'paid': 0.0};
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SummaryCard(
|
||||
label: s.pendingReceivable,
|
||||
amount: summary['pending'] ?? 0.0,
|
||||
currencyCode: currencyCode,
|
||||
color: AppColors.pending,
|
||||
bgColor: AppColors.pendingBg,
|
||||
icon: Icons.hourglass_empty_rounded,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SummaryCard(
|
||||
label: s.collected,
|
||||
amount: summary['paid'] ?? 0.0,
|
||||
currencyCode: currencyCode,
|
||||
color: AppColors.success,
|
||||
bgColor: AppColors.successBg,
|
||||
icon: Icons.check_circle_outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
PillTabs(
|
||||
tabs: [s.pending, s.collected],
|
||||
selected: _tabController.index,
|
||||
onSelect: (i) => _tabController.animateTo(i),
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_FinanceTab(
|
||||
status: 'pending',
|
||||
sort: _sort,
|
||||
onPaymentMade: _loadSummary,
|
||||
currencyCode: currencyCode,
|
||||
),
|
||||
_FinanceTab(
|
||||
status: 'paid',
|
||||
sort: _sort,
|
||||
onPaymentMade: _loadSummary,
|
||||
currencyCode: currencyCode,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SummaryCard extends StatelessWidget {
|
||||
const _SummaryCard({
|
||||
required this.label,
|
||||
required this.amount,
|
||||
required this.currencyCode,
|
||||
required this.color,
|
||||
required this.bgColor,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final double amount;
|
||||
final String currencyCode;
|
||||
final Color color;
|
||||
final Color bgColor;
|
||||
final IconData icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.06),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(icon, color: color, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
CurrencyFormatter.format(amount, currencyCode),
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: color,
|
||||
height: 1),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FinanceTab extends ConsumerStatefulWidget {
|
||||
const _FinanceTab({
|
||||
required this.status,
|
||||
required this.sort,
|
||||
required this.onPaymentMade,
|
||||
required this.currencyCode,
|
||||
});
|
||||
|
||||
final String status;
|
||||
final _FinanceSort sort;
|
||||
final VoidCallback onPaymentMade;
|
||||
final String currencyCode;
|
||||
|
||||
@override
|
||||
ConsumerState<_FinanceTab> createState() => _FinanceTabState();
|
||||
}
|
||||
|
||||
class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
late Future<List<FinanceEntry>> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_future = ClinicFinanceRepository.instance.listEntries(
|
||||
tenantId,
|
||||
status: widget.status,
|
||||
limit: 100,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
List<FinanceEntry> _sorted(List<FinanceEntry> entries) {
|
||||
final list = List<FinanceEntry>.from(entries);
|
||||
switch (widget.sort) {
|
||||
case _FinanceSort.newestFirst:
|
||||
list.sort((a, b) {
|
||||
final da = a.dateCreated != null
|
||||
? DateTime.tryParse(a.dateCreated!)
|
||||
: null;
|
||||
final db = b.dateCreated != null
|
||||
? DateTime.tryParse(b.dateCreated!)
|
||||
: null;
|
||||
if (da == null && db == null) return 0;
|
||||
if (da == null) return 1;
|
||||
if (db == null) return -1;
|
||||
return db.compareTo(da);
|
||||
});
|
||||
case _FinanceSort.byAmountDesc:
|
||||
list.sort((a, b) => b.amount.compareTo(a.amount));
|
||||
case _FinanceSort.byAmountAsc:
|
||||
list.sort((a, b) => a.amount.compareTo(b.amount));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
Future<void> _markPaid(FinanceEntry entry) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Ödeme Onayı'),
|
||||
content: Text(
|
||||
'${entry.counterpartyName ?? "Bu kayıt"} için '
|
||||
'${CurrencyFormatter.format(entry.amount, widget.currencyCode)} tutarındaki borcu '
|
||||
'ödendi olarak işaretlemek istiyor musunuz?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('İptal'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Ödendi'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
try {
|
||||
await ClinicFinanceRepository.instance.markPaid(entry.id);
|
||||
_load();
|
||||
widget.onPaymentMade();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ödeme kaydedildi.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RefreshIndicator(
|
||||
color: AppColors.accent,
|
||||
onRefresh: () async => _load(),
|
||||
child: FutureBuilder<List<FinanceEntry>>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent));
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
child: const Icon(Icons.wifi_off_rounded,
|
||||
color: AppColors.cancelled, size: 30),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Hata: ${snap.error}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('Tekrar Dene'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final entries = _sorted(snap.data!);
|
||||
if (entries.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(20)),
|
||||
child: const Icon(Icons.receipt_long_outlined,
|
||||
color: AppColors.inProgress, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Kayıt bulunamadı',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
itemCount: entries.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
final isPending = entry.status == FinanceStatus.pending;
|
||||
final statusColor =
|
||||
isPending ? AppColors.pending : AppColors.success;
|
||||
final statusBg =
|
||||
isPending ? AppColors.pendingBg : AppColors.successBg;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Material(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: isPending ? () => _markPaid(entry) : null,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2))
|
||||
]),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: statusBg,
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(
|
||||
isPending
|
||||
? Icons.hourglass_empty_rounded
|
||||
: Icons.check_circle_outline,
|
||||
color: statusColor,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.counterpartyName ??
|
||||
'Bilinmiyor',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
CurrencyFormatter.format(
|
||||
entry.amount, widget.currencyCode),
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: statusColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (entry.patientCode != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Protokol: ${entry.patientCode}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
if (entry.dateCreated != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_formatDate(entry.dateCreated!),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textMuted),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isPending) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.pending,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'Öde',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(String dateStr) {
|
||||
try {
|
||||
final d = DateTime.parse(dateStr);
|
||||
return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
|
||||
} catch (_) {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/services/realtime_service.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/job.dart';
|
||||
import '../../../models/job_file.dart';
|
||||
import '../../../features/shared/job_files_repository.dart';
|
||||
import '../../../features/shared/job_files_panel.dart';
|
||||
import '../../../core/services/job_history_service.dart';
|
||||
import 'clinic_jobs_repository.dart';
|
||||
|
||||
class ClinicJobDetailScreen extends ConsumerStatefulWidget {
|
||||
const ClinicJobDetailScreen({super.key, required this.jobId});
|
||||
final String jobId;
|
||||
|
||||
@override
|
||||
ConsumerState<ClinicJobDetailScreen> createState() =>
|
||||
_ClinicJobDetailScreenState();
|
||||
}
|
||||
|
||||
class _ClinicJobDetailScreenState
|
||||
extends ConsumerState<ClinicJobDetailScreen> {
|
||||
Job? _job;
|
||||
String? _loadError;
|
||||
late Future<List<JobFile>> _filesFuture;
|
||||
bool _isActing = false;
|
||||
late UnsubFn _unsub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
_loadFiles();
|
||||
_unsub = RealtimeService.instance.watch(
|
||||
'jobs',
|
||||
topic: widget.jobId,
|
||||
onEvent: (_) { if (mounted && !_isActing) _load(); },
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unsub();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
if (mounted) setState(() { _loadError = null; });
|
||||
try {
|
||||
final job = await ClinicJobsRepository.instance.getJob(widget.jobId);
|
||||
if (mounted) setState(() { _job = job; });
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _loadError = e.toString(); });
|
||||
}
|
||||
}
|
||||
|
||||
void _loadFiles() {
|
||||
setState(() {
|
||||
_filesFuture = JobFilesRepository.instance.listForJob(widget.jobId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _approve(Job job) async {
|
||||
setState(() => _isActing = true);
|
||||
try {
|
||||
final updated = await ClinicJobsRepository.instance.approveAtClinic(job.id, job);
|
||||
if (mounted) {
|
||||
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('İş onaylandı.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isActing = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cancelJob(Job job) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('İşi İptal Et'),
|
||||
content: const Text('Bu iş geri alınamaz şekilde iptal edilir. Onaylıyor musunuz?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Vazgeç')),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('İptal Et'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
setState(() => _isActing = true);
|
||||
try {
|
||||
final updated = await ClinicJobsRepository.instance.cancelJob(job.id, job);
|
||||
if (mounted) {
|
||||
setState(() { _job = _job!.copyWith(status: updated.status); _isActing = false; });
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('İş iptal edildi.')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isActing = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requestRevision(Job job) async {
|
||||
final noteController = TextEditingController();
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Revizyon Talebi'),
|
||||
content: TextField(
|
||||
controller: noteController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Açıklama',
|
||||
hintText: 'Revizyon sebebini belirtin...',
|
||||
),
|
||||
minLines: 3,
|
||||
maxLines: 5,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('İptal'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Gönder'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
if (noteController.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Lütfen bir açıklama girin.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isActing = true);
|
||||
try {
|
||||
final updated = await ClinicJobsRepository.instance.requestRevision(
|
||||
job.id,
|
||||
job,
|
||||
note: noteController.text.trim(),
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Revizyon talebi gönderildi.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isActing = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _markDelivered(Job job) async {
|
||||
final noteCtrl = TextEditingController();
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Teslim Alındı'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Bu işi teslim aldığınızı onaylıyor musunuz?'),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: noteCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Teslimat notu (isteğe bağlı)',
|
||||
hintText: 'Teslim eden kişi, durum vb...',
|
||||
isDense: true,
|
||||
),
|
||||
maxLines: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('İptal'),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: AppColors.success),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Teslim Alındı'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
setState(() => _isActing = true);
|
||||
try {
|
||||
final note = noteCtrl.text.trim().isNotEmpty ? noteCtrl.text.trim() : null;
|
||||
final updated = await ClinicJobsRepository.instance.markDelivered(job.id, job, note: note);
|
||||
if (mounted) {
|
||||
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('İş teslim alındı olarak işaretlendi.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isActing = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(title: const Text('İş Detayı')),
|
||||
body: _buildBody(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_job == null && _loadError == null) {
|
||||
return const Center(child: CircularProgressIndicator(color: AppColors.accent));
|
||||
}
|
||||
if (_loadError != null && _job == null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
child: const Icon(Icons.wifi_off_rounded,
|
||||
color: AppColors.cancelled, size: 30),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Hata: $_loadError',
|
||||
style: const TextStyle(color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('Tekrar Dene'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_job == null) return const Center(child: CircularProgressIndicator(color: AppColors.accent));
|
||||
final job = _job!;
|
||||
final membership = ref.read(authProvider).activeTenant;
|
||||
final canDeliver = membership?.canDeliverJobs ?? true;
|
||||
final canCancel = membership?.canCancelJobs ?? true;
|
||||
final canManage = !(membership?.isDeliveryOnly ?? false);
|
||||
return _JobDetailBody(
|
||||
job: job,
|
||||
filesFuture: _filesFuture,
|
||||
isActing: _isActing,
|
||||
canDeliver: canDeliver,
|
||||
canManage: canManage,
|
||||
onApprove: canManage ? () => _approve(job) : () {},
|
||||
onRevision: canManage ? () => _requestRevision(job) : () {},
|
||||
onDelivered: () => _markDelivered(job),
|
||||
onCancel: (canCancel && job.status == JobStatus.pending) ? () => _cancelJob(job) : null,
|
||||
onFilesRefresh: _loadFiles,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JobDetailBody extends StatelessWidget {
|
||||
const _JobDetailBody({
|
||||
required this.job,
|
||||
required this.filesFuture,
|
||||
required this.isActing,
|
||||
required this.canDeliver,
|
||||
required this.canManage,
|
||||
required this.onApprove,
|
||||
required this.onRevision,
|
||||
required this.onDelivered,
|
||||
required this.onFilesRefresh,
|
||||
this.onCancel,
|
||||
});
|
||||
|
||||
final Job job;
|
||||
final Future<List<JobFile>> filesFuture;
|
||||
final bool isActing;
|
||||
final bool canDeliver;
|
||||
final bool canManage;
|
||||
final VoidCallback onApprove;
|
||||
final VoidCallback onRevision;
|
||||
final VoidCallback onDelivered;
|
||||
final VoidCallback? onCancel;
|
||||
final VoidCallback onFilesRefresh;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final steps = job.stepTemplate;
|
||||
final currentStepIndex =
|
||||
job.currentStep != null ? steps.indexOf(job.currentStep!) : -1;
|
||||
|
||||
final canApproveOrRevise = canManage &&
|
||||
job.location == JobLocation.atClinic &&
|
||||
job.status == JobStatus.inProgress;
|
||||
final canMarkDelivered = canDeliver && job.status == JobStatus.sent;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Info card
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Patient code + status
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
job.patientCode,
|
||||
style: Theme.of(context).textTheme.headlineSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
_StatusBadge(status: job.status),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(height: 1, color: AppColors.border),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Patient + Lab
|
||||
_SectionLabel(title: 'Hasta & Laboratuvar'),
|
||||
_InfoRow(label: 'Protokol No', value: job.patientCode),
|
||||
if (job.patientId != null)
|
||||
_InfoRow(label: 'Hasta ID', value: job.patientId!),
|
||||
_InfoRow(
|
||||
label: 'Laboratuvar', value: job.labName ?? 'Bilinmiyor'),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Prosthetic
|
||||
_SectionLabel(title: 'Protez Bilgisi'),
|
||||
_InfoRow(label: 'Tür', value: job.prostheticType.label),
|
||||
_InfoRow(label: 'Üye Sayısı', value: '${job.memberCount}'),
|
||||
if (job.teeth.isNotEmpty)
|
||||
_InfoRow(label: 'Dişler', value: job.teeth.join(', ')),
|
||||
if (job.color != null && job.color!.isNotEmpty)
|
||||
_InfoRow(label: 'Renk', value: job.color!),
|
||||
if (job.description != null && job.description!.isNotEmpty)
|
||||
_InfoRow(label: 'Açıklama', value: job.description!),
|
||||
if (job.dueDate != null)
|
||||
_InfoRow(label: 'Son Tarih', value: _formatDate(job.dueDate!, withTime: true)),
|
||||
if (job.price != null)
|
||||
_InfoRow(
|
||||
label: 'Fiyat',
|
||||
value:
|
||||
'${job.price!.toStringAsFixed(2)} ${job.currency ?? 'TRY'}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Stepper card
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'İş Adımları',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_StepperWidget(
|
||||
steps: steps,
|
||||
currentStepIndex: currentStepIndex,
|
||||
historyFuture: JobHistoryService.instance.listForJob(job.id),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Action buttons
|
||||
if (isActing)
|
||||
const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent))
|
||||
else if (canApproveOrRevise) ...[
|
||||
FilledButton.icon(
|
||||
onPressed: onApprove,
|
||||
icon: const Icon(Icons.check_circle_outline),
|
||||
label: const Text('Onayla'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onRevision,
|
||||
icon: const Icon(Icons.replay_outlined),
|
||||
label: const Text('Revizyon İste'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
foregroundColor: AppColors.pending,
|
||||
side: const BorderSide(color: AppColors.pending),
|
||||
),
|
||||
),
|
||||
] else if (canMarkDelivered)
|
||||
FilledButton.icon(
|
||||
onPressed: onDelivered,
|
||||
icon: const Icon(Icons.inventory_2_outlined),
|
||||
label: const Text('Teslim Aldım'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
),
|
||||
),
|
||||
|
||||
if (onCancel != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onCancel,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
label: const Text('İşi İptal Et'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
foregroundColor: AppColors.cancelled,
|
||||
side: const BorderSide(color: AppColors.cancelled),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Files panel
|
||||
JobFilesPanel(
|
||||
job: job,
|
||||
filesFuture: filesFuture,
|
||||
onRefresh: onFilesRefresh,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Oluşturulma: ${_formatDate(job.dateCreated)}',
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime d, {bool withTime = false}) {
|
||||
final s = '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
|
||||
if (!withTime || (d.hour == 0 && d.minute == 0)) return s;
|
||||
return '$s ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
class _StepperWidget extends StatelessWidget {
|
||||
const _StepperWidget({
|
||||
required this.steps,
|
||||
required this.currentStepIndex,
|
||||
required this.historyFuture,
|
||||
});
|
||||
|
||||
final List<JobStep> steps;
|
||||
final int currentStepIndex;
|
||||
final Future<List<JobHistoryEntry>> historyFuture;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<JobHistoryEntry>>(
|
||||
future: historyFuture,
|
||||
builder: (ctx, snap) {
|
||||
final history = snap.data ?? [];
|
||||
final Map<JobStep, int> revisionCounts = {};
|
||||
for (final e in history) {
|
||||
if (e.action == JobHistoryAction.revisionRequested && e.step != null) {
|
||||
revisionCounts[e.step!] = (revisionCounts[e.step!] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: steps.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final step = entry.value;
|
||||
final isCompleted = index < currentStepIndex;
|
||||
final isCurrent = index == currentStepIndex;
|
||||
final revCount = revisionCounts[step] ?? 0;
|
||||
|
||||
Color dotColor;
|
||||
IconData dotIcon;
|
||||
if (isCompleted) {
|
||||
dotColor = AppColors.success;
|
||||
dotIcon = Icons.check_circle;
|
||||
} else if (isCurrent) {
|
||||
dotColor = AppColors.inProgress;
|
||||
dotIcon = Icons.radio_button_checked;
|
||||
} else {
|
||||
dotColor = AppColors.muted;
|
||||
dotIcon = Icons.radio_button_unchecked;
|
||||
}
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Icon(dotIcon, color: dotColor, size: 24),
|
||||
if (index < steps.length - 1)
|
||||
Container(
|
||||
width: 2,
|
||||
height: 44,
|
||||
color: index < currentStepIndex
|
||||
? AppColors.success.withValues(alpha: 0.35)
|
||||
: AppColors.border,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 2, bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
step.label,
|
||||
style: TextStyle(
|
||||
fontWeight: isCurrent
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
color: isCompleted
|
||||
? AppColors.success
|
||||
: isCurrent
|
||||
? AppColors.inProgress
|
||||
: AppColors.textMuted,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
if (revCount > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'$revCount revizyon',
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.cancelled,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (isCurrent)
|
||||
Text(
|
||||
step.description,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionLabel extends StatelessWidget {
|
||||
const _SectionLabel({required this.title});
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.accent,
|
||||
letterSpacing: 0.5),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
const _InfoRow({required this.label, required this.value});
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
const _StatusBadge({required this.status});
|
||||
final JobStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = _color(status);
|
||||
final bg = _bg(status);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
status.label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _color(JobStatus s) {
|
||||
switch (s) {
|
||||
case JobStatus.pending:
|
||||
return AppColors.pending;
|
||||
case JobStatus.inProgress:
|
||||
return AppColors.inProgress;
|
||||
case JobStatus.sent:
|
||||
return AppColors.accent;
|
||||
case JobStatus.delivered:
|
||||
return AppColors.success;
|
||||
case JobStatus.cancelled:
|
||||
return AppColors.cancelled;
|
||||
}
|
||||
}
|
||||
|
||||
Color _bg(JobStatus s) {
|
||||
switch (s) {
|
||||
case JobStatus.pending:
|
||||
return AppColors.pendingBg;
|
||||
case JobStatus.inProgress:
|
||||
return AppColors.inProgressBg;
|
||||
case JobStatus.sent:
|
||||
return AppColors.inProgressBg;
|
||||
case JobStatus.delivered:
|
||||
return AppColors.successBg;
|
||||
case JobStatus.cancelled:
|
||||
return AppColors.cancelledBg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'dart:async';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../core/services/job_history_service.dart';
|
||||
import '../../../models/job.dart';
|
||||
|
||||
const _listExpand = 'clinic_tenant_id,lab_tenant_id';
|
||||
const _detailExpand = 'clinic_tenant_id,lab_tenant_id,patient_id,prosthetic_id';
|
||||
|
||||
class ClinicJobsRepository {
|
||||
ClinicJobsRepository._();
|
||||
static final instance = ClinicJobsRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<List<Job>> listOutbound(
|
||||
String clinicTenantId, {
|
||||
List<String>? statuses,
|
||||
String? location,
|
||||
String? filterExtra,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) async {
|
||||
final filterParts = ['clinic_tenant_id = "$clinicTenantId"'];
|
||||
if (statuses != null && statuses.isNotEmpty) {
|
||||
final statusFilter = statuses.map((s) => 'status = "$s"').join(' || ');
|
||||
filterParts.add('($statusFilter)');
|
||||
}
|
||||
if (location != null) {
|
||||
filterParts.add('location = "$location"');
|
||||
}
|
||||
if (filterExtra != null) {
|
||||
filterParts.add('($filterExtra)');
|
||||
}
|
||||
|
||||
final result = await _pb.collection('jobs').getList(
|
||||
page: page,
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
expand: _listExpand,
|
||||
);
|
||||
return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated)));
|
||||
}
|
||||
|
||||
Future<Job> getJob(String jobId) async {
|
||||
final record = await _pb.collection('jobs').getOne(jobId, expand: _detailExpand);
|
||||
return Job.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<Job> createJob({
|
||||
required String clinicTenantId,
|
||||
required String labTenantId,
|
||||
required String patientCode,
|
||||
required String prostheticId,
|
||||
required ProstheticType prostheticType,
|
||||
required List<String> teeth,
|
||||
String? patientId,
|
||||
String? color,
|
||||
String? description,
|
||||
String? dueDate,
|
||||
bool provaRequired = true,
|
||||
}) async {
|
||||
final record = await _pb.collection('jobs').create(body: {
|
||||
'clinic_tenant_id': clinicTenantId,
|
||||
'lab_tenant_id': labTenantId,
|
||||
'patient_code': patientCode,
|
||||
if (patientId != null) 'patient_id': patientId,
|
||||
'prosthetic_id': prostheticId,
|
||||
'prosthetic_type': prostheticType.value,
|
||||
'member_count': teeth.length,
|
||||
'teeth': teeth,
|
||||
if (color != null) 'color': color,
|
||||
if (description != null) 'description': description,
|
||||
if (dueDate != null) 'due_date': dueDate,
|
||||
'status': 'pending',
|
||||
'location': 'at_clinic',
|
||||
'prova_required': provaRequired,
|
||||
});
|
||||
return Job.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<Job> approveAtClinic(String jobId, Job job, {String? note}) async {
|
||||
final nextStep = job.nextStep;
|
||||
if (nextStep == null) throw Exception('Bu aşamadan ileri gidilemez.');
|
||||
|
||||
final record = await _pb.collection('jobs').update(jobId, body: {
|
||||
'current_step': nextStep.value,
|
||||
'location': 'at_lab',
|
||||
});
|
||||
final updated = Job.fromJson(record.toJson());
|
||||
unawaited(JobHistoryService.instance.append(
|
||||
jobId: jobId,
|
||||
clinicTenantId: job.clinicTenantId,
|
||||
labTenantId: job.labTenantId,
|
||||
action: JobHistoryAction.approved,
|
||||
step: job.currentStep,
|
||||
note: note,
|
||||
));
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<Job> requestRevision(String jobId, Job job, {required String note}) async {
|
||||
final record = await _pb.collection('jobs').update(jobId, body: {
|
||||
'location': 'at_lab',
|
||||
});
|
||||
final updated = Job.fromJson(record.toJson());
|
||||
unawaited(JobHistoryService.instance.append(
|
||||
jobId: jobId,
|
||||
clinicTenantId: job.clinicTenantId,
|
||||
labTenantId: job.labTenantId,
|
||||
action: JobHistoryAction.revisionRequested,
|
||||
step: job.currentStep,
|
||||
note: note,
|
||||
));
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<Job> markDelivered(String jobId, Job job, {String? note}) async {
|
||||
final record = await _pb.collection('jobs').update(jobId, body: {
|
||||
'status': 'delivered',
|
||||
});
|
||||
unawaited(JobHistoryService.instance.append(
|
||||
jobId: jobId,
|
||||
clinicTenantId: job.clinicTenantId,
|
||||
labTenantId: job.labTenantId,
|
||||
action: JobHistoryAction.delivered,
|
||||
note: note,
|
||||
));
|
||||
return Job.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<Job> cancelJob(String jobId, Job job) async {
|
||||
final record = await _pb.collection('jobs').update(jobId, body: {
|
||||
'status': 'cancelled',
|
||||
});
|
||||
unawaited(JobHistoryService.instance.append(
|
||||
jobId: jobId,
|
||||
clinicTenantId: job.clinicTenantId,
|
||||
labTenantId: job.labTenantId,
|
||||
action: JobHistoryAction.cancelled,
|
||||
));
|
||||
return Job.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> listApprovedLabs(String clinicTenantId) async {
|
||||
final result = await _pb.collection('connections').getList(
|
||||
filter: 'clinic_tenant_id = "$clinicTenantId" && status = "approved"',
|
||||
expand: 'lab_tenant_id',
|
||||
perPage: 100,
|
||||
);
|
||||
return result.items.map((r) {
|
||||
final expand = r.toJson()['expand'] as Map<String, dynamic>?;
|
||||
return expand?['lab_tenant_id'] as Map<String, dynamic>? ?? {'id': r.data['lab_tenant_id']};
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<List<Job>> listJobsByPatient(String patientId, {int limit = 50}) async {
|
||||
final result = await _pb.collection('jobs').getList(
|
||||
filter: 'patient_id = "$patientId"',
|
||||
perPage: limit,
|
||||
expand: _listExpand,
|
||||
);
|
||||
return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated)));
|
||||
}
|
||||
|
||||
Future<int> countDelivered(String clinicTenantId, {DateTime? from, DateTime? to}) async {
|
||||
final parts = ['clinic_tenant_id = "$clinicTenantId"', 'status = "delivered"'];
|
||||
if (from != null) parts.add('updated >= "${_date(from)}"');
|
||||
if (to != null) parts.add('updated < "${_date(to)}"');
|
||||
final r = await _pb.collection('jobs').getList(perPage: 1, filter: parts.join(' && '));
|
||||
return r.totalItems;
|
||||
}
|
||||
|
||||
static String _date(DateTime d) => d.toIso8601String().split('T').first;
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/router/app_router.dart';
|
||||
import '../../../core/services/realtime_service.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/gradient_app_bar.dart';
|
||||
import '../../../core/widgets/pill_tabs.dart';
|
||||
import '../../../models/job.dart';
|
||||
import 'clinic_jobs_repository.dart';
|
||||
|
||||
enum _JobSort { newestFirst, oldestFirst, byDueDate, byType }
|
||||
|
||||
const _kSortLabels = [
|
||||
'Yeniden Eskiye',
|
||||
'Eskiden Yeniye',
|
||||
'Vade Tarihine Göre',
|
||||
'Türe Göre',
|
||||
];
|
||||
|
||||
class ClinicJobsScreen extends ConsumerStatefulWidget {
|
||||
const ClinicJobsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ClinicJobsScreen> createState() => _ClinicJobsScreenState();
|
||||
}
|
||||
|
||||
class _ClinicJobsScreenState extends ConsumerState<ClinicJobsScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
final _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
_JobSort _sort = _JobSort.newestFirst;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 5, vsync: this);
|
||||
_tabController.addListener(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
setState(() => _searchQuery = value);
|
||||
}
|
||||
|
||||
Future<void> _showSortOptions() async {
|
||||
final result = await showSortSheet(
|
||||
context,
|
||||
title: 'Sıralama',
|
||||
options: _kSortLabels,
|
||||
current: _sort.index,
|
||||
);
|
||||
if (result != null) {
|
||||
setState(() => _sort = _JobSort.values[result]);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isSortActive = _sort != _JobSort.newestFirst;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: GradientAppBar(
|
||||
title: 'İşlerim',
|
||||
category: 'KLİNİK',
|
||||
searchController: _searchController,
|
||||
onSearchChanged: _onSearchChanged,
|
||||
searchHint: 'Protokol, laboratuvar veya tür ara...',
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _showSortOptions,
|
||||
tooltip: 'Sırala',
|
||||
icon: Badge(
|
||||
isLabelVisible: isSortActive,
|
||||
smallSize: 8,
|
||||
backgroundColor: AppColors.accent,
|
||||
child: const Icon(Icons.sort_rounded),
|
||||
),
|
||||
),
|
||||
if (ref.watch(authProvider).activeTenant?.canCreateJobs ?? true)
|
||||
IconButton(
|
||||
onPressed: () => context.push(routeClinicJobNew),
|
||||
tooltip: 'Yeni İş',
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
PillTabs(
|
||||
tabs: const ['Tümü', 'Onay Bekleyen', 'Lab\'da', 'Teslimat', 'Teslim Alındı'],
|
||||
selected: _tabController.index,
|
||||
onSelect: (i) => _tabController.animateTo(i),
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_JobsTab(
|
||||
statuses: const ['pending', 'in_progress', 'sent', 'delivered'],
|
||||
searchQuery: _searchQuery,
|
||||
sort: _sort,
|
||||
),
|
||||
_JobsTab(
|
||||
statuses: const ['in_progress'],
|
||||
location: 'at_clinic',
|
||||
searchQuery: _searchQuery,
|
||||
sort: _sort,
|
||||
),
|
||||
_JobsTab(
|
||||
filterExtra: 'status = "pending" || (status = "in_progress" && location = "at_lab")',
|
||||
searchQuery: _searchQuery,
|
||||
sort: _sort,
|
||||
),
|
||||
_JobsTab(
|
||||
statuses: const ['sent'],
|
||||
searchQuery: _searchQuery,
|
||||
sort: _sort,
|
||||
),
|
||||
_JobsTab(
|
||||
statuses: const ['delivered'],
|
||||
searchQuery: _searchQuery,
|
||||
sort: _sort,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JobsTab extends ConsumerStatefulWidget {
|
||||
const _JobsTab({
|
||||
this.statuses,
|
||||
this.location,
|
||||
this.filterExtra,
|
||||
required this.searchQuery,
|
||||
required this.sort,
|
||||
});
|
||||
|
||||
final List<String>? statuses;
|
||||
final String? location;
|
||||
final String? filterExtra;
|
||||
final String searchQuery;
|
||||
final _JobSort sort;
|
||||
|
||||
@override
|
||||
ConsumerState<_JobsTab> createState() => _JobsTabState();
|
||||
}
|
||||
|
||||
class _JobsTabState extends ConsumerState<_JobsTab> {
|
||||
final List<Job> _jobs = [];
|
||||
bool _isLoading = false;
|
||||
bool _hasMore = true;
|
||||
int _page = 1;
|
||||
static const _limit = 20;
|
||||
String? _error;
|
||||
late UnsubFn _unsub;
|
||||
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
_scrollController.addListener(_onScroll);
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
_unsub = RealtimeService.instance.watch(
|
||||
'jobs',
|
||||
filter: 'clinic_tenant_id="$tenantId"',
|
||||
onEvent: (_) { if (mounted) _load(); },
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unsub();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 200 &&
|
||||
!_isLoading &&
|
||||
_hasMore) {
|
||||
_loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
if (_isLoading) return;
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
_page = 1;
|
||||
_jobs.clear();
|
||||
_hasMore = true;
|
||||
});
|
||||
await _fetch();
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
if (_isLoading || !_hasMore) return;
|
||||
_page++;
|
||||
await _fetch();
|
||||
}
|
||||
|
||||
Future<void> _fetch() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
final results = await ClinicJobsRepository.instance.listOutbound(
|
||||
tenantId,
|
||||
statuses: widget.statuses,
|
||||
location: widget.location,
|
||||
filterExtra: widget.filterExtra,
|
||||
page: _page,
|
||||
limit: _limit,
|
||||
);
|
||||
setState(() {
|
||||
_jobs.addAll(results);
|
||||
_hasMore = results.length == _limit;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
List<Job> get _filtered {
|
||||
var list = _jobs.toList();
|
||||
|
||||
final q = widget.searchQuery.toLowerCase().trim();
|
||||
if (q.isNotEmpty) {
|
||||
list = list.where((j) {
|
||||
return j.patientCode.toLowerCase().contains(q) ||
|
||||
(j.labName?.toLowerCase().contains(q) ?? false) ||
|
||||
j.prostheticType.label.toLowerCase().contains(q);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
switch (widget.sort) {
|
||||
case _JobSort.newestFirst:
|
||||
list.sort((a, b) => b.dateCreated.compareTo(a.dateCreated));
|
||||
case _JobSort.oldestFirst:
|
||||
list.sort((a, b) => a.dateCreated.compareTo(b.dateCreated));
|
||||
case _JobSort.byDueDate:
|
||||
list.sort((a, b) {
|
||||
if (a.dueDate == null && b.dueDate == null) return 0;
|
||||
if (a.dueDate == null) return 1;
|
||||
if (b.dueDate == null) return -1;
|
||||
return a.dueDate!.compareTo(b.dueDate!);
|
||||
});
|
||||
case _JobSort.byType:
|
||||
list.sort(
|
||||
(a, b) => a.prostheticType.label.compareTo(b.prostheticType.label));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_error != null && _jobs.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
child: const Icon(Icons.wifi_off_rounded,
|
||||
color: AppColors.cancelled, size: 30),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Hata: $_error',
|
||||
style: const TextStyle(color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('Tekrar Dene'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_isLoading && _jobs.isEmpty) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent));
|
||||
}
|
||||
|
||||
final filtered = _filtered;
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(20)),
|
||||
child: const Icon(Icons.work_off_outlined,
|
||||
color: AppColors.inProgress, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.searchQuery.isNotEmpty
|
||||
? 'Sonuç bulunamadı'
|
||||
: 'Henüz iş yok',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
color: AppColors.accent,
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
itemCount:
|
||||
filtered.length + (_hasMore && widget.searchQuery.isEmpty ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == filtered.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent)),
|
||||
);
|
||||
}
|
||||
final job = filtered[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _JobListCard(
|
||||
job: job,
|
||||
onTap: () => context.push('/clinic/jobs/${job.id}'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JobListCard extends StatelessWidget {
|
||||
const _JobListCard({required this.job, required this.onTap});
|
||||
|
||||
final Job job;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final statusColor = _statusColor(job);
|
||||
final statusBg = _statusBg(job);
|
||||
final isOverdue =
|
||||
job.dueDate != null && job.dueDate!.isBefore(DateTime.now());
|
||||
|
||||
return Semantics(
|
||||
label: job.patientCode,
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
child: Material(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2))
|
||||
]),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: statusBg,
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(Icons.medical_services_outlined,
|
||||
color: statusColor, size: 20),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
job.patientCode,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
_StatusBadge(status: job.status, location: job.location),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(job.prostheticType.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppColors.textSecondary)),
|
||||
if (job.labName != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
job.labName!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppColors.textMuted),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
if (job.dueDate != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.calendar_today_outlined,
|
||||
size: 11,
|
||||
color: isOverdue
|
||||
? AppColors.cancelled
|
||||
: AppColors.textMuted),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
_fmt(job.dueDate!),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isOverdue
|
||||
? AppColors.cancelled
|
||||
: AppColors.textMuted,
|
||||
fontWeight: isOverdue
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.chevron_right,
|
||||
color: AppColors.textMuted, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _fmt(DateTime d) =>
|
||||
'${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
|
||||
|
||||
Color _statusColor(Job job) {
|
||||
if (job.status == JobStatus.inProgress && job.location == JobLocation.atClinic) return AppColors.pending;
|
||||
switch (job.status) {
|
||||
case JobStatus.pending: return AppColors.pending;
|
||||
case JobStatus.inProgress: return AppColors.inProgress;
|
||||
case JobStatus.sent: return AppColors.accent;
|
||||
case JobStatus.delivered: return AppColors.success;
|
||||
case JobStatus.cancelled: return AppColors.cancelled;
|
||||
}
|
||||
}
|
||||
|
||||
Color _statusBg(Job job) {
|
||||
if (job.status == JobStatus.inProgress && job.location == JobLocation.atClinic) return AppColors.pendingBg;
|
||||
switch (job.status) {
|
||||
case JobStatus.pending: return AppColors.pendingBg;
|
||||
case JobStatus.inProgress: return AppColors.inProgressBg;
|
||||
case JobStatus.sent: return AppColors.inProgressBg;
|
||||
case JobStatus.delivered: return AppColors.successBg;
|
||||
case JobStatus.cancelled: return AppColors.cancelledBg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
const _StatusBadge({required this.status, required this.location});
|
||||
|
||||
final JobStatus status;
|
||||
final JobLocation location;
|
||||
|
||||
String get _label {
|
||||
if (status == JobStatus.inProgress && location == JobLocation.atClinic) return 'Onay Bekliyor';
|
||||
if (status == JobStatus.sent) return 'Teslimat Bekliyor';
|
||||
return status.label;
|
||||
}
|
||||
|
||||
Color get _color {
|
||||
if (status == JobStatus.inProgress && location == JobLocation.atClinic) return AppColors.pending;
|
||||
switch (status) {
|
||||
case JobStatus.pending: return AppColors.pending;
|
||||
case JobStatus.inProgress: return AppColors.inProgress;
|
||||
case JobStatus.sent: return AppColors.accent;
|
||||
case JobStatus.delivered: return AppColors.success;
|
||||
case JobStatus.cancelled: return AppColors.cancelled;
|
||||
}
|
||||
}
|
||||
|
||||
Color get _bg {
|
||||
if (status == JobStatus.inProgress && location == JobLocation.atClinic) return AppColors.pendingBg;
|
||||
switch (status) {
|
||||
case JobStatus.pending: return AppColors.pendingBg;
|
||||
case JobStatus.inProgress: return AppColors.inProgressBg;
|
||||
case JobStatus.sent: return AppColors.inProgressBg;
|
||||
case JobStatus.delivered: return AppColors.successBg;
|
||||
case JobStatus.cancelled: return AppColors.cancelledBg;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: _bg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_label,
|
||||
style: TextStyle(
|
||||
color: _color,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,717 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/job.dart';
|
||||
import '../../../models/patient.dart';
|
||||
import '../jobs/clinic_jobs_repository.dart';
|
||||
import 'clinic_patients_repository.dart';
|
||||
|
||||
class ClinicPatientDetailScreen extends ConsumerStatefulWidget {
|
||||
const ClinicPatientDetailScreen({super.key, required this.patientId});
|
||||
final String patientId;
|
||||
|
||||
@override
|
||||
ConsumerState<ClinicPatientDetailScreen> createState() =>
|
||||
_ClinicPatientDetailScreenState();
|
||||
}
|
||||
|
||||
class _ClinicPatientDetailScreenState
|
||||
extends ConsumerState<ClinicPatientDetailScreen> {
|
||||
late Future<Patient> _future;
|
||||
late Future<List<Job>> _jobsFuture;
|
||||
bool _editMode = false;
|
||||
bool _isSaving = false;
|
||||
|
||||
final _patientCodeController = TextEditingController();
|
||||
final _firstNameController = TextEditingController();
|
||||
final _lastNameController = TextEditingController();
|
||||
final _phoneController = TextEditingController();
|
||||
final _notesController = TextEditingController();
|
||||
String? _birthDate;
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_patientCodeController.dispose();
|
||||
_firstNameController.dispose();
|
||||
_lastNameController.dispose();
|
||||
_phoneController.dispose();
|
||||
_notesController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
setState(() {
|
||||
_future = ClinicPatientsRepository.instance
|
||||
.getPatient(widget.patientId)
|
||||
.then((p) {
|
||||
_populateControllers(p);
|
||||
return p;
|
||||
});
|
||||
_jobsFuture = ClinicJobsRepository.instance
|
||||
.listJobsByPatient(widget.patientId);
|
||||
});
|
||||
}
|
||||
|
||||
void _populateControllers(Patient p) {
|
||||
_patientCodeController.text = p.patientCode;
|
||||
_firstNameController.text = p.firstName ?? '';
|
||||
_lastNameController.text = p.lastName ?? '';
|
||||
_phoneController.text = p.phone ?? '';
|
||||
_notesController.text = p.notes ?? '';
|
||||
_birthDate = p.birthDate;
|
||||
}
|
||||
|
||||
Future<void> _pickBirthDate() async {
|
||||
DateTime initial = DateTime(1990);
|
||||
if (_birthDate != null) {
|
||||
try {
|
||||
initial = DateTime.parse(_birthDate!);
|
||||
} catch (_) {}
|
||||
}
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: initial,
|
||||
firstDate: DateTime(1900),
|
||||
lastDate: DateTime.now(),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_birthDate = picked.toIso8601String().split('T').first;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _isSaving = true);
|
||||
try {
|
||||
final patch = <String, dynamic>{
|
||||
'patient_code': _patientCodeController.text.trim(),
|
||||
'first_name': _firstNameController.text.trim().isNotEmpty
|
||||
? _firstNameController.text.trim()
|
||||
: null,
|
||||
'last_name': _lastNameController.text.trim().isNotEmpty
|
||||
? _lastNameController.text.trim()
|
||||
: null,
|
||||
'phone': _phoneController.text.trim().isNotEmpty
|
||||
? _phoneController.text.trim()
|
||||
: null,
|
||||
'birth_date': _birthDate,
|
||||
'notes': _notesController.text.trim().isNotEmpty
|
||||
? _notesController.text.trim()
|
||||
: null,
|
||||
};
|
||||
final updated = await ClinicPatientsRepository.instance
|
||||
.updatePatient(widget.patientId, patch);
|
||||
_populateControllers(updated);
|
||||
setState(() {
|
||||
_editMode = false;
|
||||
_future = Future.value(updated);
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Hasta bilgileri güncellendi.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSaving = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _delete() async {
|
||||
// Check for existing jobs first
|
||||
List<Job>? jobs;
|
||||
try {
|
||||
jobs = await ClinicJobsRepository.instance
|
||||
.listJobsByPatient(widget.patientId, limit: 1);
|
||||
} catch (_) {
|
||||
jobs = null;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
final hasJobs = (jobs?.isNotEmpty) ?? false;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Hastayı Sil'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Bu hastayı silmek istediğinizden emin misiniz?'),
|
||||
if (hasJobs) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded,
|
||||
color: AppColors.cancelled, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Bu hastaya ait işler bulunmaktadır. Hasta silinirse bu bağlantı kopar.',
|
||||
style: TextStyle(
|
||||
fontSize: 13, color: AppColors.cancelled),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Vazgeç')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style:
|
||||
FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
|
||||
child: const Text('Sil'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
try {
|
||||
await ClinicPatientsRepository.instance.deletePatient(widget.patientId);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Hasta silindi.')),
|
||||
);
|
||||
Navigator.of(context).pop(true); // signal that a delete happened
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Silme hatası: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Hasta Detayı'),
|
||||
actions: [
|
||||
if (!_editMode) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
tooltip: 'Düzenle',
|
||||
onPressed: () => setState(() => _editMode = true),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: AppColors.cancelled),
|
||||
tooltip: 'Sil',
|
||||
onPressed: _delete,
|
||||
),
|
||||
] else ...[
|
||||
if (_isSaving)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() => _editMode = false);
|
||||
_future.then(_populateControllers);
|
||||
},
|
||||
child: const Text('İptal'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _save,
|
||||
child: const Text('Kaydet'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
body: FutureBuilder<Patient>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Hata: ${snap.error}'),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: _load,
|
||||
child: const Text('Tekrar Dene'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_editMode) {
|
||||
return _EditForm(
|
||||
formKey: _formKey,
|
||||
patientCodeController: _patientCodeController,
|
||||
firstNameController: _firstNameController,
|
||||
lastNameController: _lastNameController,
|
||||
phoneController: _phoneController,
|
||||
notesController: _notesController,
|
||||
birthDate: _birthDate,
|
||||
onPickBirthDate: _pickBirthDate,
|
||||
);
|
||||
}
|
||||
|
||||
final patient = snap.data!;
|
||||
return _PatientView(patient: patient, jobsFuture: _jobsFuture);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── View ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class _PatientView extends StatelessWidget {
|
||||
const _PatientView({required this.patient, required this.jobsFuture});
|
||||
final Patient patient;
|
||||
final Future<List<Job>> jobsFuture;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Avatar + name header
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(20)),
|
||||
child: Center(
|
||||
child: Text(
|
||||
patient.displayName.isNotEmpty
|
||||
? patient.displayName[0].toUpperCase()
|
||||
: '?',
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.inProgress),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
patient.displayName,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
patient.patientCode,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_DetailRow(label: 'Hasta Kodu', value: patient.patientCode),
|
||||
if (patient.firstName != null)
|
||||
_DetailRow(label: 'Ad', value: patient.firstName!),
|
||||
if (patient.lastName != null)
|
||||
_DetailRow(label: 'Soyad', value: patient.lastName!),
|
||||
if (patient.phone != null && patient.phone!.isNotEmpty)
|
||||
_DetailRow(label: 'Telefon', value: patient.phone!),
|
||||
if (patient.birthDate != null && patient.birthDate!.isNotEmpty)
|
||||
_DetailRow(
|
||||
label: 'Doğum Tarihi', value: patient.birthDate!),
|
||||
if (patient.notes != null && patient.notes!.isNotEmpty)
|
||||
_DetailRow(label: 'Notlar', value: patient.notes!),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Job history
|
||||
_JobHistory(jobsFuture: jobsFuture),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Job history ────────────────────────────────────────────────────────────
|
||||
|
||||
class _JobHistory extends StatelessWidget {
|
||||
const _JobHistory({required this.jobsFuture});
|
||||
final Future<List<Job>> jobsFuture;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'İŞ GEÇMİŞİ',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textSecondary,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FutureBuilder<List<Job>>(
|
||||
future: jobsFuture,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Text('Yüklenemedi: ${snap.error}',
|
||||
style:
|
||||
const TextStyle(color: AppColors.textSecondary));
|
||||
}
|
||||
final jobs = snap.data ?? [];
|
||||
if (jobs.isEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Henüz iş kaydı yok.',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: jobs.asMap().entries.map((entry) {
|
||||
final i = entry.key;
|
||||
final job = entry.value;
|
||||
final isLast = i == jobs.length - 1;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: _statusBg(job.status),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
_statusIcon(job.status),
|
||||
color: _statusColor(job.status),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
job.patientCode,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${job.prostheticType.label} · ${_statusLabel(job.status)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatDate(job.dateCreated),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!isLast)
|
||||
const Divider(
|
||||
height: 1,
|
||||
indent: 68,
|
||||
color: AppColors.border),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static Color _statusBg(JobStatus s) => switch (s) {
|
||||
JobStatus.delivered => AppColors.successBg,
|
||||
JobStatus.cancelled => AppColors.cancelledBg,
|
||||
JobStatus.inProgress => AppColors.inProgressBg,
|
||||
_ => AppColors.pendingBg,
|
||||
};
|
||||
|
||||
static Color _statusColor(JobStatus s) => switch (s) {
|
||||
JobStatus.delivered => AppColors.success,
|
||||
JobStatus.cancelled => AppColors.cancelled,
|
||||
JobStatus.inProgress => AppColors.inProgress,
|
||||
_ => AppColors.pending,
|
||||
};
|
||||
|
||||
static IconData _statusIcon(JobStatus s) => switch (s) {
|
||||
JobStatus.delivered => Icons.check_circle_outline,
|
||||
JobStatus.cancelled => Icons.cancel_outlined,
|
||||
JobStatus.inProgress => Icons.autorenew,
|
||||
_ => Icons.hourglass_empty_outlined,
|
||||
};
|
||||
|
||||
static String _statusLabel(JobStatus s) => switch (s) {
|
||||
JobStatus.pending => 'Bekliyor',
|
||||
JobStatus.inProgress => 'Üretimde',
|
||||
JobStatus.sent => 'Gönderildi',
|
||||
JobStatus.delivered => 'Teslim Edildi',
|
||||
JobStatus.cancelled => 'İptal',
|
||||
};
|
||||
|
||||
static String _formatDate(DateTime d) {
|
||||
return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _DetailRow extends StatelessWidget {
|
||||
const _DetailRow({required this.label, required this.value});
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edit form ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _EditForm extends StatelessWidget {
|
||||
const _EditForm({
|
||||
required this.formKey,
|
||||
required this.patientCodeController,
|
||||
required this.firstNameController,
|
||||
required this.lastNameController,
|
||||
required this.phoneController,
|
||||
required this.notesController,
|
||||
required this.birthDate,
|
||||
required this.onPickBirthDate,
|
||||
});
|
||||
|
||||
final GlobalKey<FormState> formKey;
|
||||
final TextEditingController patientCodeController;
|
||||
final TextEditingController firstNameController;
|
||||
final TextEditingController lastNameController;
|
||||
final TextEditingController phoneController;
|
||||
final TextEditingController notesController;
|
||||
final String? birthDate;
|
||||
final VoidCallback onPickBirthDate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: patientCodeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Hasta Kodu *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (val) =>
|
||||
(val == null || val.trim().isEmpty)
|
||||
? 'Hasta kodu zorunludur'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: firstNameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Ad',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: lastNameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Soyad',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: phoneController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Telefon',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
InkWell(
|
||||
onTap: onPickBirthDate,
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Doğum Tarihi',
|
||||
border: OutlineInputBorder(),
|
||||
suffixIcon: Icon(Icons.calendar_today),
|
||||
),
|
||||
child: Text(
|
||||
birthDate ?? 'Tarih seçin',
|
||||
style: birthDate != null
|
||||
? null
|
||||
: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: notesController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Notlar',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
minLines: 3,
|
||||
maxLines: 5,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../models/patient.dart';
|
||||
|
||||
class ClinicPatientsRepository {
|
||||
ClinicPatientsRepository._();
|
||||
static final instance = ClinicPatientsRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<List<Patient>> listPatients(
|
||||
String tenantId, {
|
||||
String? search,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) async {
|
||||
final filterParts = ['tenant_id = "$tenantId"'];
|
||||
if (search != null && search.isNotEmpty) {
|
||||
filterParts.add(
|
||||
'(patient_code ~ "$search" || first_name ~ "$search" || last_name ~ "$search")',
|
||||
);
|
||||
}
|
||||
|
||||
final result = await _pb.collection('patients').getList(
|
||||
page: page,
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
);
|
||||
return (result.items.map((r) => Patient.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => a.patientCode.compareTo(b.patientCode)));
|
||||
}
|
||||
|
||||
Future<Patient> getPatient(String patientId) async {
|
||||
final record = await _pb.collection('patients').getOne(patientId);
|
||||
return Patient.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<Patient> createPatient({
|
||||
required String tenantId,
|
||||
required String patientCode,
|
||||
String? firstName,
|
||||
String? lastName,
|
||||
String? birthDate,
|
||||
String? phone,
|
||||
String? notes,
|
||||
}) async {
|
||||
final record = await _pb.collection('patients').create(body: {
|
||||
'tenant_id': tenantId,
|
||||
'patient_code': patientCode,
|
||||
if (firstName != null) 'first_name': firstName,
|
||||
if (lastName != null) 'last_name': lastName,
|
||||
if (birthDate != null) 'birth_date': birthDate,
|
||||
if (phone != null) 'phone': phone,
|
||||
if (notes != null) 'notes': notes,
|
||||
});
|
||||
return Patient.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<Patient> updatePatient(String patientId, Map<String, dynamic> patch) async {
|
||||
final record = await _pb.collection('patients').update(patientId, body: patch);
|
||||
return Patient.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<void> deletePatient(String patientId) async {
|
||||
await _pb.collection('patients').delete(patientId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/gradient_app_bar.dart';
|
||||
import '../../../models/patient.dart';
|
||||
import 'clinic_patients_repository.dart';
|
||||
|
||||
enum _PatientSort { nameAZ, nameZA, byCode }
|
||||
|
||||
const _kSortLabels = [
|
||||
'Ada Göre (A → Z)',
|
||||
'Ada Göre (Z → A)',
|
||||
'Hasta Koduna Göre',
|
||||
];
|
||||
|
||||
void _showAdaptive(BuildContext context, Widget content) {
|
||||
final isDesktop =
|
||||
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||
if (isDesktop) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => content,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ClinicPatientsScreen extends ConsumerStatefulWidget {
|
||||
const ClinicPatientsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ClinicPatientsScreen> createState() =>
|
||||
_ClinicPatientsScreenState();
|
||||
}
|
||||
|
||||
class _ClinicPatientsScreenState extends ConsumerState<ClinicPatientsScreen> {
|
||||
late Future<List<Patient>> _future;
|
||||
final _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
_PatientSort _sort = _PatientSort.nameAZ;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _load([String? search]) {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_future = ClinicPatientsRepository.instance.listPatients(
|
||||
tenantId,
|
||||
search: search?.trim().isNotEmpty == true ? search : null,
|
||||
limit: 100,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
setState(() => _searchQuery = value);
|
||||
_load(value);
|
||||
}
|
||||
|
||||
Future<void> _showSortOptions() async {
|
||||
final result = await showSortSheet(
|
||||
context,
|
||||
title: 'Sıralama',
|
||||
options: _kSortLabels,
|
||||
current: _sort.index,
|
||||
);
|
||||
if (result != null) {
|
||||
setState(() => _sort = _PatientSort.values[result]);
|
||||
}
|
||||
}
|
||||
|
||||
List<Patient> _sorted(List<Patient> patients) {
|
||||
final list = List<Patient>.from(patients);
|
||||
switch (_sort) {
|
||||
case _PatientSort.nameAZ:
|
||||
list.sort((a, b) => a.displayName.compareTo(b.displayName));
|
||||
case _PatientSort.nameZA:
|
||||
list.sort((a, b) => b.displayName.compareTo(a.displayName));
|
||||
case _PatientSort.byCode:
|
||||
list.sort((a, b) => a.patientCode.compareTo(b.patientCode));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
void _showNewPatientSheet() {
|
||||
_showAdaptive(
|
||||
context,
|
||||
_NewPatientSheet(
|
||||
onCreated: () {
|
||||
Navigator.of(context).pop();
|
||||
_load(_searchQuery);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Hasta oluşturuldu.')),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isSortActive = _sort != _PatientSort.nameAZ;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: GradientAppBar(
|
||||
title: 'Hastalar',
|
||||
category: 'KLİNİK',
|
||||
searchController: _searchController,
|
||||
onSearchChanged: _onSearchChanged,
|
||||
searchHint: 'Ad, soyad veya kod ile arayın...',
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _showSortOptions,
|
||||
tooltip: 'Sırala',
|
||||
icon: Badge(
|
||||
isLabelVisible: isSortActive,
|
||||
smallSize: 8,
|
||||
backgroundColor: AppColors.accent,
|
||||
child: const Icon(Icons.sort_rounded),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _showNewPatientSheet,
|
||||
tooltip: 'Yeni Hasta',
|
||||
icon: const Icon(Icons.person_add_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
color: AppColors.accent,
|
||||
onRefresh: () async => _load(_searchQuery),
|
||||
child: FutureBuilder<List<Patient>>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.accent));
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
child: const Icon(Icons.wifi_off_rounded,
|
||||
color: AppColors.cancelled, size: 30),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Hata: ${snap.error}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _load(_searchQuery),
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('Tekrar Dene'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final patients = _sorted(snap.data!);
|
||||
if (patients.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(20)),
|
||||
child: const Icon(Icons.people_outline,
|
||||
color: AppColors.inProgress, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_searchQuery.isNotEmpty
|
||||
? 'Sonuç bulunamadı'
|
||||
: 'Henüz hasta yok',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
if (_searchQuery.isEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Yeni hasta eklemek için + düğmesine dokunun',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
itemCount: patients.length,
|
||||
itemBuilder: (context, index) {
|
||||
final patient = patients[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _PatientCard(
|
||||
patient: patient,
|
||||
onTap: () => context
|
||||
.push('/clinic/patients/${patient.id}'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PatientCard extends StatelessWidget {
|
||||
const _PatientCard({required this.patient, required this.onTap});
|
||||
|
||||
final Patient patient;
|
||||
final VoidCallback onTap;
|
||||
|
||||
String get _initials {
|
||||
final name = patient.displayName;
|
||||
if (name.isEmpty) return '?';
|
||||
final parts = name.trim().split(' ');
|
||||
if (parts.length >= 2) {
|
||||
return '${parts.first[0]}${parts.last[0]}'.toUpperCase();
|
||||
}
|
||||
return name[0].toUpperCase();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2))
|
||||
]),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF1E3A5F), Color(0xFF0369A1)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(13),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
_initials,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
patient.displayName.isNotEmpty
|
||||
? patient.displayName
|
||||
: patient.patientCode,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
patient.patientCode,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (patient.phone != null &&
|
||||
patient.phone!.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.phone_outlined,
|
||||
size: 11, color: AppColors.textMuted),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
patient.phone!,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: AppColors.textMuted),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right,
|
||||
color: AppColors.textMuted, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── New Patient Sheet ─────────────────────────────────────────────────────────
|
||||
|
||||
class _NewPatientSheet extends ConsumerStatefulWidget {
|
||||
const _NewPatientSheet({required this.onCreated});
|
||||
|
||||
final VoidCallback onCreated;
|
||||
|
||||
@override
|
||||
ConsumerState<_NewPatientSheet> createState() => _NewPatientSheetState();
|
||||
}
|
||||
|
||||
class _NewPatientSheetState extends ConsumerState<_NewPatientSheet> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _patientCodeController = TextEditingController();
|
||||
final _firstNameController = TextEditingController();
|
||||
final _lastNameController = TextEditingController();
|
||||
final _phoneController = TextEditingController();
|
||||
final _notesController = TextEditingController();
|
||||
String? _birthDate;
|
||||
bool _isSubmitting = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_patientCodeController.dispose();
|
||||
_firstNameController.dispose();
|
||||
_lastNameController.dispose();
|
||||
_phoneController.dispose();
|
||||
_notesController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickBirthDate() async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: DateTime(1990),
|
||||
firstDate: DateTime(1900),
|
||||
lastDate: DateTime.now(),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_birthDate = picked.toIso8601String().split('T').first;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
await ClinicPatientsRepository.instance.createPatient(
|
||||
tenantId: tenantId,
|
||||
patientCode: _patientCodeController.text.trim(),
|
||||
firstName: _firstNameController.text.trim().isNotEmpty
|
||||
? _firstNameController.text.trim()
|
||||
: null,
|
||||
lastName: _lastNameController.text.trim().isNotEmpty
|
||||
? _lastNameController.text.trim()
|
||||
: null,
|
||||
phone: _phoneController.text.trim().isNotEmpty
|
||||
? _phoneController.text.trim()
|
||||
: null,
|
||||
birthDate: _birthDate,
|
||||
notes: _notesController.text.trim().isNotEmpty
|
||||
? _notesController.text.trim()
|
||||
: null,
|
||||
);
|
||||
widget.onCreated();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDesktop =
|
||||
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: isDesktop ? Radius.zero : const Radius.circular(20),
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: isDesktop ? 0 : MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Yeni Hasta',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _patientCodeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Hasta Kodu *',
|
||||
hintText: 'Ör: P-001',
|
||||
),
|
||||
validator: (val) =>
|
||||
(val == null || val.trim().isEmpty)
|
||||
? 'Hasta kodu zorunludur'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _firstNameController,
|
||||
decoration: const InputDecoration(labelText: 'Ad'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _lastNameController,
|
||||
decoration: const InputDecoration(labelText: 'Soyad'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _phoneController,
|
||||
decoration: const InputDecoration(labelText: 'Telefon'),
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
InkWell(
|
||||
onTap: _pickBirthDate,
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Doğum Tarihi',
|
||||
suffixIcon: Icon(Icons.calendar_today),
|
||||
),
|
||||
child: Text(
|
||||
_birthDate ?? 'Tarih seçin',
|
||||
style: _birthDate != null
|
||||
? null
|
||||
: const TextStyle(color: AppColors.textMuted),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _notesController,
|
||||
decoration: const InputDecoration(labelText: 'Notlar'),
|
||||
minLines: 2,
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_isSubmitting)
|
||||
const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent))
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 48),
|
||||
),
|
||||
child: const Text('Hasta Oluştur'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,680 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/l10n/app_strings.dart';
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/providers/locale_provider.dart';
|
||||
import '../../../core/router/app_router.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/tenant.dart';
|
||||
import '../../shared/tenant_team_screen.dart';
|
||||
import '../connections/clinic_connections_screen.dart';
|
||||
|
||||
class ClinicSettingsScreen extends ConsumerWidget {
|
||||
const ClinicSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final auth = ref.watch(authProvider);
|
||||
final s = ref.watch(stringsProvider);
|
||||
final profile = auth.profile;
|
||||
final membership = auth.activeTenant;
|
||||
final tenant = membership?.tenant;
|
||||
final canEdit = membership?.isAdmin ?? false;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(s.settings)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// User card
|
||||
_SectionHeader(title: s.userInfo),
|
||||
_UserCard(profile: profile),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Clinic info
|
||||
_SectionHeader(
|
||||
title: s.clinicInfo,
|
||||
action: canEdit
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.edit_outlined,
|
||||
size: 18, color: AppColors.accent),
|
||||
tooltip: s.edit,
|
||||
onPressed: () => _showEditSheet(context, ref, tenant, s),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
_InfoCard(children: [
|
||||
_InfoTile(
|
||||
icon: Icons.business,
|
||||
label: s.clinicName,
|
||||
value: tenant?.companyName ?? '-',
|
||||
),
|
||||
_InfoTile(
|
||||
icon: Icons.category_outlined,
|
||||
label: s.type,
|
||||
value: _tenantKindLabel(tenant?.kind, s),
|
||||
),
|
||||
_InfoTile(
|
||||
icon: Icons.star_outline,
|
||||
label: s.role,
|
||||
value: _roleLabel(membership?.role, s),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Connections
|
||||
if (membership?.showConnections ?? false) ...[
|
||||
_SectionHeader(title: s.connections),
|
||||
_InfoCard(children: [
|
||||
_NavTile(
|
||||
icon: Icons.link_rounded,
|
||||
iconColor: AppColors.inProgress,
|
||||
iconBg: AppColors.inProgressBg,
|
||||
title: s.labConnections,
|
||||
subtitle: s.labConnectionsSub,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const ClinicConnectionsScreen()),
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Other memberships
|
||||
if (auth.memberships.length > 1) ...[
|
||||
_SectionHeader(title: s.otherMemberships),
|
||||
_InfoCard(children: [
|
||||
for (final m
|
||||
in auth.memberships.where((m) => m.id != membership?.id))
|
||||
_NavTile(
|
||||
icon: Icons.switch_account_outlined,
|
||||
iconColor: AppColors.inProgress,
|
||||
iconBg: AppColors.inProgressBg,
|
||||
title: m.tenant.companyName,
|
||||
subtitle: _tenantKindLabel(m.tenant.kind, s),
|
||||
onTap: () {
|
||||
ref.read(authProvider.notifier).setActiveTenant(m);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(s.tenantSelected(m.tenant.companyName))),
|
||||
);
|
||||
},
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Team management + Reports
|
||||
if (membership?.canManageUsers ?? false) ...[
|
||||
_SectionHeader(title: s.management),
|
||||
_InfoCard(children: [
|
||||
_NavTile(
|
||||
icon: Icons.group_outlined,
|
||||
iconColor: AppColors.inProgress,
|
||||
iconBg: AppColors.inProgressBg,
|
||||
title: s.team,
|
||||
subtitle: s.teamSub,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const TenantTeamScreen()),
|
||||
),
|
||||
),
|
||||
_NavTile(
|
||||
icon: Icons.bar_chart_rounded,
|
||||
iconColor: AppColors.accent,
|
||||
iconBg: AppColors.inProgressBg,
|
||||
title: s.reports,
|
||||
subtitle: s.reportsSub,
|
||||
onTap: () => context.push(routeClinicReports),
|
||||
),
|
||||
_NavTile(
|
||||
icon: Icons.auto_awesome_outlined,
|
||||
iconColor: const Color(0xFF7C3AED),
|
||||
iconBg: const Color(0xFFF3E8FF),
|
||||
title: s.aiAssistant,
|
||||
subtitle: s.aiAssistantSub,
|
||||
onTap: () => context.push(routeClinicAi),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Preferences (language)
|
||||
_SectionHeader(title: s.preferences),
|
||||
_InfoCard(children: [
|
||||
_NavTile(
|
||||
icon: Icons.language_outlined,
|
||||
iconColor: AppColors.accent,
|
||||
iconBg: AppColors.inProgressBg,
|
||||
title: s.appLanguage,
|
||||
subtitle: _currentLanguageLabel(ref.watch(localeProvider).languageCode, s),
|
||||
onTap: () => _showLanguagePicker(context, ref, s),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Sign out
|
||||
_SignOutCard(ref: ref, s: s),
|
||||
const SizedBox(height: 32),
|
||||
const Center(
|
||||
child: Text('DLS — Dental Lab System',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditSheet(BuildContext context, WidgetRef ref, Tenant? tenant, AppStrings s) {
|
||||
if (tenant == null) return;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _EditTenantSheet(
|
||||
tenant: tenant,
|
||||
s: s,
|
||||
onSave: (name) async {
|
||||
await ref
|
||||
.read(authProvider.notifier)
|
||||
.updateTenantInfo(tenantId: tenant.id, companyName: name);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showLanguagePicker(BuildContext context, WidgetRef ref, AppStrings s) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _LanguagePickerSheet(s: s, ref: ref),
|
||||
);
|
||||
}
|
||||
|
||||
static String _currentLanguageLabel(String code, AppStrings s) => switch (code) {
|
||||
'en' => s.languageEnglish,
|
||||
'ru' => s.languageRussian,
|
||||
'ar' => s.languageArabic,
|
||||
'de' => s.languageGerman,
|
||||
_ => s.languageTurkish,
|
||||
};
|
||||
|
||||
static String _tenantKindLabel(TenantKind? kind, AppStrings s) =>
|
||||
switch (kind) {
|
||||
TenantKind.clinic => s.tenantKindClinic,
|
||||
TenantKind.lab => s.tenantKindLab,
|
||||
null => '-',
|
||||
};
|
||||
|
||||
static String _roleLabel(TenantRole? role, AppStrings s) => switch (role) {
|
||||
TenantRole.owner => s.roleOwner,
|
||||
TenantRole.admin => s.roleAdmin,
|
||||
TenantRole.technician => s.roleTechnician,
|
||||
TenantRole.delivery => s.roleDelivery,
|
||||
TenantRole.finance => s.roleFinance,
|
||||
TenantRole.doctor => s.roleDoctor,
|
||||
TenantRole.member => s.roleMember,
|
||||
null => '-',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Language picker sheet ─────────────────────────────────────────────────────
|
||||
|
||||
class _LanguagePickerSheet extends ConsumerWidget {
|
||||
const _LanguagePickerSheet({required this.s, required this.ref});
|
||||
final AppStrings s;
|
||||
final WidgetRef ref;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef _) {
|
||||
final currentLocale = ref.watch(localeProvider);
|
||||
final options = [
|
||||
('tr', '🇹🇷', s.languageTurkish),
|
||||
('en', '🇬🇧', s.languageEnglish),
|
||||
('ru', '🇷🇺', s.languageRussian),
|
||||
('ar', '🇸🇦', s.languageArabic),
|
||||
('de', '🇩🇪', s.languageGerman),
|
||||
];
|
||||
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.border,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
s.languageSelection,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
for (final (code, flag, label) in options)
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
leading: Text(flag, style: const TextStyle(fontSize: 24)),
|
||||
title: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
trailing: currentLocale.languageCode == code
|
||||
? const Icon(Icons.check_circle_rounded,
|
||||
color: AppColors.accent)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(localeProvider.notifier).setLocale(Locale(code));
|
||||
ref.read(authProvider.notifier).updateLanguage(code);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
SizedBox(height: MediaQuery.paddingOf(context).bottom + 4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edit sheet ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _EditTenantSheet extends StatefulWidget {
|
||||
const _EditTenantSheet({
|
||||
required this.tenant,
|
||||
required this.s,
|
||||
required this.onSave,
|
||||
});
|
||||
final Tenant tenant;
|
||||
final AppStrings s;
|
||||
final Future<void> Function(String companyName) onSave;
|
||||
|
||||
@override
|
||||
State<_EditTenantSheet> createState() => _EditTenantSheetState();
|
||||
}
|
||||
|
||||
class _EditTenantSheetState extends State<_EditTenantSheet> {
|
||||
late final TextEditingController _nameController;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameController = TextEditingController(text: widget.tenant.companyName);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final name = _nameController.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
setState(() => _saving = true);
|
||||
final navigator = Navigator.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await widget.onSave(name);
|
||||
navigator.pop();
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('${widget.s.errorPrefix}: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = widget.s;
|
||||
return Padding(
|
||||
padding:
|
||||
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.border,
|
||||
borderRadius: BorderRadius.circular(2)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(s.editClinicInfo,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: s.clinicName,
|
||||
hintText: s.clinicNameHint,
|
||||
),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_saving)
|
||||
const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent))
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 48)),
|
||||
child: Text(s.save),
|
||||
),
|
||||
SizedBox(height: MediaQuery.paddingOf(context).bottom + 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reusable UI pieces ────────────────────────────────────────────────────────
|
||||
|
||||
class _UserCard extends StatelessWidget {
|
||||
const _UserCard({required this.profile});
|
||||
final dynamic profile;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final displayName = (profile?.displayName?.isNotEmpty == true)
|
||||
? profile!.displayName as String
|
||||
: 'Kullanıcı';
|
||||
final initial = (profile?.displayName?.isNotEmpty == true
|
||||
? (profile!.displayName as String)[0]
|
||||
: (profile?.email as String?)?[0] ?? '?')
|
||||
.toUpperCase();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(14)),
|
||||
child: Center(
|
||||
child: Text(initial,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.inProgress)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 2),
|
||||
Text(profile?.email as String? ?? '',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.title, this.action});
|
||||
final String title;
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.accent,
|
||||
letterSpacing: 0.3),
|
||||
),
|
||||
),
|
||||
if (action != null) action!,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoCard extends StatelessWidget {
|
||||
const _InfoCard({required this.children});
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < children.length; i++) ...[
|
||||
children[i],
|
||||
if (i < children.length - 1)
|
||||
const Divider(
|
||||
height: 1, indent: 16, endIndent: 16, color: AppColors.border),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoTile extends StatelessWidget {
|
||||
const _InfoTile(
|
||||
{required this.icon, required this.label, required this.value});
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: AppColors.textSecondary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: AppColors.textMuted)),
|
||||
const SizedBox(height: 2),
|
||||
Text(value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavTile extends StatelessWidget {
|
||||
const _NavTile({
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.iconBg,
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final Color iconBg;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||
leading: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: iconBg, borderRadius: BorderRadius.circular(9)),
|
||||
child: Icon(icon, color: iconColor, size: 18),
|
||||
),
|
||||
title: Text(title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
subtitle: subtitle != null
|
||||
? Text(subtitle!,
|
||||
style: const TextStyle(color: AppColors.textSecondary))
|
||||
: null,
|
||||
trailing:
|
||||
const Icon(Icons.chevron_right, color: AppColors.textSecondary),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SignOutCard extends StatelessWidget {
|
||||
const _SignOutCard({required this.ref, required this.s});
|
||||
final WidgetRef ref;
|
||||
final AppStrings s;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.cancelledBg),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||
leading: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(9)),
|
||||
child: const Icon(Icons.logout,
|
||||
color: AppColors.cancelled, size: 18),
|
||||
),
|
||||
title: Text(s.signOut,
|
||||
style: const TextStyle(
|
||||
color: AppColors.cancelled, fontWeight: FontWeight.w600)),
|
||||
onTap: () async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(s.signOutTitle),
|
||||
content: Text(s.signOutConfirm),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(s.cancel)),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.cancelled),
|
||||
child: Text(s.signOut),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await ref.read(authProvider.notifier).signOut();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/connection.dart';
|
||||
import '../../../models/job.dart';
|
||||
import 'connection_stats_repository.dart';
|
||||
|
||||
class ConnectionDetailScreen extends StatefulWidget {
|
||||
const ConnectionDetailScreen({
|
||||
super.key,
|
||||
required this.connection,
|
||||
required this.labTenantId,
|
||||
});
|
||||
|
||||
final Connection connection;
|
||||
final String labTenantId;
|
||||
|
||||
@override
|
||||
State<ConnectionDetailScreen> createState() => _ConnectionDetailScreenState();
|
||||
}
|
||||
|
||||
class _ConnectionDetailScreenState extends State<ConnectionDetailScreen> {
|
||||
late Future<ConnectionStats> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
setState(() {
|
||||
_future = ConnectionStatsRepository.instance.fetchStats(
|
||||
labTenantId: widget.labTenantId,
|
||||
clinicTenantId: widget.connection.clinicTenantId,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final conn = widget.connection;
|
||||
final clinicName = conn.clinicName ?? 'Klinik';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
pinned: true,
|
||||
expandedHeight: 140,
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF0F172A), AppColors.primary],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 48, 20, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: const Icon(Icons.local_hospital_outlined, color: Colors.white, size: 26),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
clinicName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
_StatusBadge(status: conn.status),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: FutureBuilder<ConnectionStats>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 80),
|
||||
child: Center(child: CircularProgressIndicator(color: AppColors.accent)),
|
||||
);
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.wifi_off_rounded, color: AppColors.cancelled, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
Text('Hata: ${snap.error}', style: const TextStyle(color: AppColors.textSecondary), textAlign: TextAlign.center),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 16),
|
||||
label: const Text('Tekrar Dene'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final stats = snap.data!;
|
||||
return _StatsBody(stats: stats);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stats body ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _StatsBody extends StatelessWidget {
|
||||
const _StatsBody({required this.stats});
|
||||
final ConnectionStats stats;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// KPI row
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _KpiCard(label: 'Toplam İş', value: '${stats.totalJobs}', icon: Icons.work_outline_rounded, color: AppColors.accent)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: _KpiCard(label: 'Aktif', value: '${stats.activeJobs}', icon: Icons.pending_outlined, color: AppColors.inProgress)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: _KpiCard(label: 'Teslim', value: '${stats.deliveredJobs}', icon: Icons.check_circle_outline, color: AppColors.success)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _KpiCard(label: 'Bu Ay', value: '${stats.thisMonthJobs}', icon: Icons.calendar_month_outlined, color: AppColors.accent)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: _KpiCard(label: 'Geçen Ay', value: '${stats.lastMonthJobs}', icon: Icons.history_rounded, color: AppColors.textSecondary)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: _KpiCard(label: 'İptal', value: '${stats.cancelledJobs}', icon: Icons.cancel_outlined, color: AppColors.cancelled)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Revenue
|
||||
_SectionTitle('Finans'),
|
||||
const SizedBox(height: 8),
|
||||
_RevenueCard(stats: stats),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Prosthetic type breakdown
|
||||
if (stats.byType.isNotEmpty) ...[
|
||||
_SectionTitle('Ürün Tipi Dağılımı'),
|
||||
const SizedBox(height: 8),
|
||||
_TypeBreakdownCard(byType: stats.byType, total: stats.totalJobs),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Monthly trend
|
||||
_SectionTitle('Aylık Karşılaştırma'),
|
||||
const SizedBox(height: 8),
|
||||
_MonthCompareCard(stats: stats),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Recent jobs
|
||||
if (stats.recentJobs.isNotEmpty) ...[
|
||||
_SectionTitle('Son İşler'),
|
||||
const SizedBox(height: 8),
|
||||
_RecentJobsCard(jobs: stats.recentJobs),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── KPI card ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class _KpiCard extends StatelessWidget {
|
||||
const _KpiCard({required this.label, required this.value, required this.icon, required this.color});
|
||||
final String label;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 6, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: color),
|
||||
const SizedBox(height: 8),
|
||||
Text(value, style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800, color: color)),
|
||||
const SizedBox(height: 2),
|
||||
Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textMuted, fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Revenue card ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _RevenueCard extends StatelessWidget {
|
||||
const _RevenueCard({required this.stats});
|
||||
final ConnectionStats stats;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final collected = stats.totalRevenue - stats.pendingRevenue;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_RevRow(
|
||||
label: 'Toplam Gelir',
|
||||
value: _fmt(stats.totalRevenue),
|
||||
color: AppColors.textPrimary,
|
||||
bold: true,
|
||||
),
|
||||
const Divider(height: 20),
|
||||
_RevRow(label: 'Tahsil Edilen', value: _fmt(collected), color: AppColors.success),
|
||||
const SizedBox(height: 8),
|
||||
_RevRow(label: 'Bekleyen Alacak', value: _fmt(stats.pendingRevenue), color: AppColors.pending),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _fmt(double v) => '${v.toStringAsFixed(0)} TL';
|
||||
}
|
||||
|
||||
class _RevRow extends StatelessWidget {
|
||||
const _RevRow({required this.label, required this.value, required this.color, this.bold = false});
|
||||
final String label;
|
||||
final String value;
|
||||
final Color color;
|
||||
final bool bold;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = TextStyle(
|
||||
fontSize: bold ? 15 : 14,
|
||||
fontWeight: bold ? FontWeight.w700 : FontWeight.w500,
|
||||
color: color,
|
||||
);
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: style.copyWith(color: bold ? AppColors.textPrimary : AppColors.textSecondary)),
|
||||
Text(value, style: style),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Type breakdown ────────────────────────────────────────────────────────────
|
||||
|
||||
class _TypeBreakdownCard extends StatelessWidget {
|
||||
const _TypeBreakdownCard({required this.byType, required this.total});
|
||||
final Map<String, int> byType;
|
||||
final int total;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sorted = byType.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < sorted.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 10),
|
||||
_TypeBar(
|
||||
label: _typeLabel(sorted[i].key),
|
||||
count: sorted[i].value,
|
||||
total: total,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _typeLabel(String key) => switch (key) {
|
||||
'metal_porselen' => 'Metal Porselen',
|
||||
'zirkonyum' => 'Zirkonyum',
|
||||
'implant_ustu_zirkonyum'=> 'İmplant Üstü Zirkonyum',
|
||||
'gecici' => 'Geçici',
|
||||
'e_max' => 'E-Max',
|
||||
'tam_protez' => 'Tam Protez',
|
||||
'parsiyel' => 'Parsiyel Protez',
|
||||
_ => 'Diğer',
|
||||
};
|
||||
}
|
||||
|
||||
class _TypeBar extends StatelessWidget {
|
||||
const _TypeBar({required this.label, required this.count, required this.total});
|
||||
final String label;
|
||||
final int count;
|
||||
final int total;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pct = total > 0 ? count / total : 0.0;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.textPrimary)),
|
||||
Text('$count adet · ${(pct * 100).toStringAsFixed(0)}%',
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: pct,
|
||||
minHeight: 7,
|
||||
backgroundColor: AppColors.border,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.accent),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Monthly compare ───────────────────────────────────────────────────────────
|
||||
|
||||
class _MonthCompareCard extends StatelessWidget {
|
||||
const _MonthCompareCard({required this.stats});
|
||||
final ConnectionStats stats;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final thisMonth = stats.thisMonthJobs;
|
||||
final lastMonth = stats.lastMonthJobs;
|
||||
final maxBar = (thisMonth > lastMonth ? thisMonth : lastMonth).clamp(1, 999);
|
||||
final trend = lastMonth == 0
|
||||
? null
|
||||
: ((thisMonth - lastMonth) / lastMonth * 100).toStringAsFixed(0);
|
||||
final isUp = thisMonth >= lastMonth;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (trend != null) ...[
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
isUp ? Icons.trending_up_rounded : Icons.trending_down_rounded,
|
||||
size: 18,
|
||||
color: isUp ? AppColors.success : AppColors.cancelled,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
isUp ? '+$trend% geçen aya göre' : '$trend% geçen aya göre',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isUp ? AppColors.success : AppColors.cancelled,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
_BarColumn(label: 'Geçen Ay', count: lastMonth, maxCount: maxBar, color: AppColors.border),
|
||||
const SizedBox(width: 16),
|
||||
_BarColumn(label: 'Bu Ay', count: thisMonth, maxCount: maxBar, color: AppColors.accent),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BarColumn extends StatelessWidget {
|
||||
const _BarColumn({required this.label, required this.count, required this.maxCount, required this.color});
|
||||
final String label;
|
||||
final int count;
|
||||
final int maxCount;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final height = maxCount > 0 ? (count / maxCount * 80).clamp(4.0, 80.0) : 4.0;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text('$count', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: color == AppColors.border ? AppColors.textSecondary : AppColors.accent)),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 40,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textMuted)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recent jobs ───────────────────────────────────────────────────────────────
|
||||
|
||||
class _RecentJobsCard extends StatelessWidget {
|
||||
const _RecentJobsCard({required this.jobs});
|
||||
final List<Job> jobs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < jobs.length; i++) ...[
|
||||
if (i > 0) const Divider(height: 1),
|
||||
_RecentJobRow(job: jobs[i]),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentJobRow extends StatelessWidget {
|
||||
const _RecentJobRow({required this.job});
|
||||
final Job job;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = job.status == JobStatus.delivered ? AppColors.success : AppColors.inProgress;
|
||||
final bg = job.status == JobStatus.delivered ? AppColors.successBg : AppColors.inProgressBg;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(10)),
|
||||
child: Center(child: Icon(Icons.assignment_outlined, size: 18, color: color)),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(job.patientCode, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
Text(job.prostheticType.label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
|
||||
child: Text(job.status.label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
const _SectionTitle(this.text);
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(text, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.textMuted, letterSpacing: 0.5));
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
const _StatusBadge({required this.status});
|
||||
final ConnectionStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (label, color) = switch (status) {
|
||||
ConnectionStatus.approved => ('Onaylı', AppColors.success),
|
||||
ConnectionStatus.pending => ('Bekliyor', AppColors.pending),
|
||||
ConnectionStatus.rejected => ('Reddedildi', AppColors.cancelled),
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../models/job.dart';
|
||||
|
||||
class ConnectionStats {
|
||||
const ConnectionStats({
|
||||
required this.totalJobs,
|
||||
required this.byStatus,
|
||||
required this.byType,
|
||||
required this.totalRevenue,
|
||||
required this.pendingRevenue,
|
||||
required this.thisMonthJobs,
|
||||
required this.lastMonthJobs,
|
||||
required this.revisionCount,
|
||||
required this.recentJobs,
|
||||
});
|
||||
|
||||
final int totalJobs;
|
||||
final Map<String, int> byStatus; // 'in_progress', 'sent', 'delivered', 'cancelled'
|
||||
final Map<String, int> byType; // prosthetic_type -> count
|
||||
final double totalRevenue;
|
||||
final double pendingRevenue;
|
||||
final int thisMonthJobs;
|
||||
final int lastMonthJobs;
|
||||
final int revisionCount;
|
||||
final List<Job> recentJobs;
|
||||
|
||||
int get deliveredJobs => byStatus['delivered'] ?? 0;
|
||||
int get activeJobs => (byStatus['in_progress'] ?? 0) + (byStatus['sent'] ?? 0);
|
||||
int get cancelledJobs => byStatus['cancelled'] ?? 0;
|
||||
double get revisionRate => totalJobs > 0 ? revisionCount / totalJobs * 100 : 0;
|
||||
double get completionRate => totalJobs > 0 ? deliveredJobs / totalJobs * 100 : 0;
|
||||
}
|
||||
|
||||
class ConnectionStatsRepository {
|
||||
ConnectionStatsRepository._();
|
||||
static final instance = ConnectionStatsRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<ConnectionStats> fetchStats({
|
||||
required String labTenantId,
|
||||
required String clinicTenantId,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
final thisMonthStart = DateTime(now.year, now.month, 1);
|
||||
final lastMonthStart = DateTime(now.year, now.month - 1, 1);
|
||||
final lastMonthEnd = thisMonthStart.subtract(const Duration(seconds: 1));
|
||||
|
||||
final filter = 'lab_tenant_id = "$labTenantId" && clinic_tenant_id = "$clinicTenantId"';
|
||||
|
||||
final results = await Future.wait([
|
||||
_pb.collection('jobs').getList(
|
||||
filter: filter,
|
||||
perPage: 500,
|
||||
expand: 'clinic_tenant_id,lab_tenant_id',
|
||||
),
|
||||
_pb.collection('finance_entries').getList(
|
||||
filter: 'tenant_id = "$labTenantId" && job_id.clinic_tenant_id = "$clinicTenantId"',
|
||||
perPage: 500,
|
||||
),
|
||||
]);
|
||||
|
||||
final jobsResult = results[0];
|
||||
final financeResult = results[1];
|
||||
|
||||
final allJobs = jobsResult.items
|
||||
.map((r) => Job.fromJson(r.toJson()))
|
||||
.toList();
|
||||
|
||||
// Status breakdown
|
||||
final byStatus = <String, int>{};
|
||||
final byType = <String, int>{};
|
||||
int thisMonthJobs = 0;
|
||||
int lastMonthJobs = 0;
|
||||
int revisionCount = 0;
|
||||
|
||||
for (final job in allJobs) {
|
||||
// Status
|
||||
final s = job.status.value;
|
||||
byStatus[s] = (byStatus[s] ?? 0) + 1;
|
||||
|
||||
// Type
|
||||
final t = job.prostheticType.value;
|
||||
byType[t] = (byType[t] ?? 0) + 1;
|
||||
|
||||
// Monthly
|
||||
final created = job.dateCreated;
|
||||
if (created.isAfter(thisMonthStart)) thisMonthJobs++;
|
||||
else if (created.isAfter(lastMonthStart) && created.isBefore(lastMonthEnd)) lastMonthJobs++;
|
||||
|
||||
// Revision
|
||||
if (job.status == JobStatus.inProgress && job.currentStep == null) revisionCount++;
|
||||
}
|
||||
|
||||
// Finance
|
||||
double totalRevenue = 0;
|
||||
double pendingRevenue = 0;
|
||||
for (final r in financeResult.items) {
|
||||
final j = r.toJson();
|
||||
final amount = (j['amount'] as num?)?.toDouble() ?? 0;
|
||||
totalRevenue += amount;
|
||||
if (j['status'] == 'pending') pendingRevenue += amount;
|
||||
}
|
||||
|
||||
// Recent jobs (last 5 delivered or sent)
|
||||
final recent = allJobs
|
||||
.where((j) => j.status == JobStatus.delivered || j.status == JobStatus.sent)
|
||||
.toList()
|
||||
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated));
|
||||
|
||||
return ConnectionStats(
|
||||
totalJobs: allJobs.length,
|
||||
byStatus: byStatus,
|
||||
byType: byType,
|
||||
totalRevenue: totalRevenue,
|
||||
pendingRevenue: pendingRevenue,
|
||||
thisMonthJobs: thisMonthJobs,
|
||||
lastMonthJobs: lastMonthJobs,
|
||||
revisionCount: revisionCount,
|
||||
recentJobs: recent.take(5).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../models/connection.dart';
|
||||
|
||||
class LabConnectionsRepository {
|
||||
LabConnectionsRepository._();
|
||||
static final instance = LabConnectionsRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<List<Connection>> listConnections(String labTenantId) async {
|
||||
final result = await _pb.collection('connections').getList(
|
||||
filter: 'lab_tenant_id = "$labTenantId"',
|
||||
expand: 'clinic_tenant_id,lab_tenant_id',
|
||||
perPage: 100,
|
||||
);
|
||||
return (result.items.map((r) => Connection.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? '')));
|
||||
}
|
||||
|
||||
Future<Connection> respondToRequest({
|
||||
required String connectionId,
|
||||
required bool approve,
|
||||
}) async {
|
||||
final record = await _pb.collection('connections').update(connectionId, body: {
|
||||
'status': approve ? 'approved' : 'rejected',
|
||||
});
|
||||
return Connection.fromJson(record.toJson());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/gradient_app_bar.dart';
|
||||
import '../../../models/connection.dart';
|
||||
import 'connection_detail_screen.dart';
|
||||
import 'lab_connections_repository.dart';
|
||||
|
||||
class LabConnectionsScreen extends ConsumerStatefulWidget {
|
||||
const LabConnectionsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LabConnectionsScreen> createState() =>
|
||||
_LabConnectionsScreenState();
|
||||
}
|
||||
|
||||
class _LabConnectionsScreenState extends ConsumerState<LabConnectionsScreen> {
|
||||
late Future<List<Connection>> _future;
|
||||
final _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_future = LabConnectionsRepository.instance.listConnections(tenantId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _respond(String connectionId, bool approve) async {
|
||||
try {
|
||||
await LabConnectionsRepository.instance.respondToRequest(
|
||||
connectionId: connectionId,
|
||||
approve: approve,
|
||||
);
|
||||
_load();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
approve ? 'Bağlantı onaylandı' : 'Bağlantı reddedildi'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Color _statusColor(ConnectionStatus status) {
|
||||
return switch (status) {
|
||||
ConnectionStatus.pending => AppColors.pending,
|
||||
ConnectionStatus.approved => AppColors.success,
|
||||
ConnectionStatus.rejected => AppColors.cancelled,
|
||||
};
|
||||
}
|
||||
|
||||
Color _statusBg(ConnectionStatus status) {
|
||||
return switch (status) {
|
||||
ConnectionStatus.pending => AppColors.pendingBg,
|
||||
ConnectionStatus.approved => AppColors.successBg,
|
||||
ConnectionStatus.rejected => AppColors.cancelledBg,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: GradientAppBar(
|
||||
title: 'Bağlantılar',
|
||||
category: 'LABORATUVAR',
|
||||
searchController: _searchController,
|
||||
onSearchChanged: (v) => setState(() => _searchQuery = v),
|
||||
searchHint: 'Klinik adı ara...',
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
color: AppColors.accent,
|
||||
onRefresh: () async => _load(),
|
||||
child: FutureBuilder<List<Connection>>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent));
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
child: const Icon(Icons.wifi_off_rounded,
|
||||
color: AppColors.cancelled, size: 30),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Hata: ${snap.error}',
|
||||
style:
|
||||
const TextStyle(color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('Tekrar Dene'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final allConnections = snap.data!;
|
||||
final q = _searchQuery.toLowerCase().trim();
|
||||
final connections = q.isEmpty
|
||||
? allConnections
|
||||
: allConnections.where((c) =>
|
||||
(c.clinicName ?? '').toLowerCase().contains(q)).toList();
|
||||
|
||||
if (allConnections.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(20)),
|
||||
child: const Icon(Icons.link_off,
|
||||
color: AppColors.inProgress, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Henüz bağlantı yok',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Kliniklerden gelen istekler burada görünür.',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary, fontSize: 13),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final pending = connections
|
||||
.where((c) => c.status == ConnectionStatus.pending)
|
||||
.toList();
|
||||
final others = connections
|
||||
.where((c) => c.status != ConnectionStatus.pending)
|
||||
.toList();
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
children: [
|
||||
if (pending.isNotEmpty) ...[
|
||||
_SectionHeader(
|
||||
label: 'Bekleyen İstekler',
|
||||
count: pending.length,
|
||||
countColor: AppColors.pending,
|
||||
countBg: AppColors.pendingBg,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...pending.map((c) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _ConnectionCard(
|
||||
connection: c,
|
||||
statusColor: _statusColor(c.status),
|
||||
statusBg: _statusBg(c.status),
|
||||
onApprove: () => _respond(c.id, true),
|
||||
onReject: () => _respond(c.id, false),
|
||||
),
|
||||
)),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (others.isNotEmpty) ...[
|
||||
_SectionHeader(
|
||||
label: 'Bağlantılar',
|
||||
count: others.length,
|
||||
countColor: AppColors.textSecondary,
|
||||
countBg: AppColors.surfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...others.map((c) {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _ConnectionCard(
|
||||
connection: c,
|
||||
statusColor: _statusColor(c.status),
|
||||
statusBg: _statusBg(c.status),
|
||||
onTap: c.status == ConnectionStatus.approved
|
||||
? () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ConnectionDetailScreen(
|
||||
connection: c,
|
||||
labTenantId: tenantId,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({
|
||||
required this.label,
|
||||
required this.count,
|
||||
required this.countColor,
|
||||
required this.countBg,
|
||||
});
|
||||
final String label;
|
||||
final int count;
|
||||
final Color countColor;
|
||||
final Color countBg;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.accent,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: countBg,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
color: countColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectionCard extends StatefulWidget {
|
||||
const _ConnectionCard({
|
||||
required this.connection,
|
||||
required this.statusColor,
|
||||
required this.statusBg,
|
||||
this.onApprove,
|
||||
this.onReject,
|
||||
this.onTap,
|
||||
});
|
||||
final Connection connection;
|
||||
final Color statusColor;
|
||||
final Color statusBg;
|
||||
final VoidCallback? onApprove;
|
||||
final VoidCallback? onReject;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
State<_ConnectionCard> createState() => _ConnectionCardState();
|
||||
}
|
||||
|
||||
class _ConnectionCardState extends State<_ConnectionCard> {
|
||||
bool _loading = false;
|
||||
|
||||
Future<void> _act(VoidCallback? cb) async {
|
||||
if (cb == null) return;
|
||||
setState(() => _loading = true);
|
||||
cb();
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
|
||||
String _formatDate(String? raw) {
|
||||
if (raw == null) return '';
|
||||
try {
|
||||
final dt = DateTime.parse(raw);
|
||||
return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = widget.connection;
|
||||
final isPending = c.status == ConnectionStatus.pending;
|
||||
|
||||
return Material(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2))
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.statusBg,
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(Icons.business_outlined,
|
||||
color: widget.statusColor, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
c.clinicName ?? 'Klinik',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
if (c.dateCreated != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_formatDate(c.dateCreated),
|
||||
style: const TextStyle(
|
||||
color: AppColors.textMuted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.statusBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
c.status.label,
|
||||
style: TextStyle(
|
||||
color: widget.statusColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.onTap != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.chevron_right_rounded,
|
||||
size: 18, color: AppColors.textMuted),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (isPending) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed:
|
||||
_loading ? null : () => _act(widget.onReject),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.cancelled,
|
||||
side: const BorderSide(color: AppColors.cancelled)),
|
||||
child: const Text('Reddet'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed:
|
||||
_loading ? null : () => _act(widget.onApprove),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.success),
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Text('Onayla'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,883 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/router/app_router.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/tooth_logo.dart';
|
||||
import '../../../core/services/realtime_service.dart';
|
||||
import '../../../models/job.dart';
|
||||
import '../jobs/lab_jobs_repository.dart';
|
||||
|
||||
class LabDashboardScreen extends ConsumerStatefulWidget {
|
||||
const LabDashboardScreen({super.key});
|
||||
@override
|
||||
ConsumerState<LabDashboardScreen> createState() => _LabDashboardScreenState();
|
||||
}
|
||||
|
||||
class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
|
||||
late Future<_DashboardData> _future;
|
||||
bool _acceptingAll = false;
|
||||
late UnsubFn _unsub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
_unsub = RealtimeService.instance.watch(
|
||||
'jobs',
|
||||
filter: "lab_tenant_id='$tenantId'",
|
||||
onEvent: (_) { if (mounted) _load(); },
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unsub();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
final now = DateTime.now();
|
||||
final thisMonthStart = DateTime(now.year, now.month, 1);
|
||||
final lastMonthStart = DateTime(now.year, now.month - 1, 1);
|
||||
setState(() {
|
||||
_future = Future.wait([
|
||||
Future.wait<List<Job>>([
|
||||
LabJobsRepository.instance.listInbound(tenantId, status: 'pending'),
|
||||
LabJobsRepository.instance.listInProgress(tenantId),
|
||||
LabJobsRepository.instance.listInProgress(tenantId, location: 'at_lab'),
|
||||
LabJobsRepository.instance.listInProgress(tenantId, location: 'at_clinic'),
|
||||
LabJobsRepository.instance.listInbound(tenantId, status: 'sent', limit: 200),
|
||||
LabJobsRepository.instance.listInbound(tenantId, status: 'delivered', limit: 200),
|
||||
]),
|
||||
LabJobsRepository.instance.countDelivered(tenantId, from: thisMonthStart),
|
||||
LabJobsRepository.instance.countDelivered(tenantId, from: lastMonthStart, to: thisMonthStart),
|
||||
]).then((r) {
|
||||
final jobs = r[0] as List<List<Job>>;
|
||||
return _DashboardData(
|
||||
pendingJobs: jobs[0],
|
||||
inProgressJobs: jobs[1],
|
||||
atLabJobs: jobs[2],
|
||||
atClinicJobs: jobs[3],
|
||||
sentCount: jobs[4].length,
|
||||
deliveredCount: jobs[5].length,
|
||||
thisMonthDelivered: r[1] as int,
|
||||
lastMonthDelivered: r[2] as int,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _bulkAccept() async {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() => _acceptingAll = true);
|
||||
try {
|
||||
await LabJobsRepository.instance.bulkAcceptPending(tenantId);
|
||||
_load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e'), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _acceptingAll = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final companyName = ref.watch(authProvider).activeTenant?.tenant.companyName ?? '';
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const maxContent = 1040.0;
|
||||
final hPad = constraints.maxWidth > maxContent
|
||||
? (constraints.maxWidth - maxContent) / 2
|
||||
: 16.0;
|
||||
|
||||
return RefreshIndicator(
|
||||
color: AppColors.accent,
|
||||
onRefresh: () async => _load(),
|
||||
child: FutureBuilder<_DashboardData>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return _DashboardSkeleton(companyName: companyName, hPad: hPad);
|
||||
}
|
||||
if (snap.hasError) return _ErrorBody(onRetry: _load);
|
||||
final data = snap.data!;
|
||||
final isDesktop = MediaQuery.sizeOf(ctx).width > AppLayout.sidebarBreakpoint;
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
_DashboardHeader(companyName: companyName),
|
||||
if (isDesktop)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: _StatsRow(
|
||||
pending: data.pendingJobs.length,
|
||||
inProgress: data.inProgressJobs.length,
|
||||
sent: data.sentCount,
|
||||
delivered: data.deliveredCount,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (data.pendingJobs.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: _AcceptAllBanner(
|
||||
count: data.pendingJobs.length,
|
||||
loading: _acceptingAll,
|
||||
onTap: _bulkAccept,
|
||||
).animate().fadeIn(duration: 300.ms).slideY(begin: 0.1, end: 0),
|
||||
),
|
||||
),
|
||||
if (isDesktop) ...[
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: _MonthlyReportSection(data: data)
|
||||
.animate().fadeIn(duration: 300.ms).slideY(begin: 0.08, end: 0),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: _GamificationRow(data: data)
|
||||
.animate().fadeIn(duration: 300.ms, delay: 60.ms).slideY(begin: 0.08, end: 0),
|
||||
),
|
||||
),
|
||||
],
|
||||
// ── Yapılacaklar (at_lab) ────────────────────────────
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Yapılacaklar', style: Theme.of(context).textTheme.titleMedium),
|
||||
TextButton(
|
||||
onPressed: () => context.go(routeLabJobsAll),
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.accent, padding: const EdgeInsets.symmetric(horizontal: 8)),
|
||||
child: const Text('Tümünü Gör'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (data.atLabJobs.isEmpty)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||
child: _EmptySection(message: 'Yapılacak iş yok'),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
|
||||
sliver: SliverList.separated(
|
||||
itemCount: data.atLabJobs.take(5).length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||
itemBuilder: (ctx, i) => _JobCard(job: data.atLabJobs[i])
|
||||
.animate(delay: (i * 60).ms)
|
||||
.fadeIn(duration: 300.ms)
|
||||
.slideY(begin: 0.12, end: 0),
|
||||
),
|
||||
),
|
||||
// ── Klinikte Onay Bekliyor (at_clinic) ───────────────
|
||||
if (data.atClinicJobs.isNotEmpty) ...[
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Text('Klinikte Onay Bekliyor', style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
|
||||
sliver: SliverList.separated(
|
||||
itemCount: data.atClinicJobs.take(5).length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||
itemBuilder: (ctx, i) => _JobCard(job: data.atClinicJobs[i])
|
||||
.animate(delay: (i * 60).ms)
|
||||
.fadeIn(duration: 300.ms)
|
||||
.slideY(begin: 0.12, end: 0),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DashboardHeader extends StatelessWidget {
|
||||
const _DashboardHeader({required this.companyName});
|
||||
final String companyName;
|
||||
|
||||
// Must stay in sync with _DesktopSidebar.headerHeight in app_router.dart
|
||||
static const double _desktopToolbarHeight = 64;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||
|
||||
if (isDesktop) {
|
||||
return SliverAppBar(
|
||||
pinned: true,
|
||||
toolbarHeight: _desktopToolbarHeight,
|
||||
backgroundColor: AppColors.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: false,
|
||||
automaticallyImplyLeading: false,
|
||||
titleSpacing: 0,
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Genel Bakış', style: TextStyle(fontSize: 11, color: AppColors.textSecondary.withValues(alpha: 0.8), letterSpacing: 0.3)),
|
||||
const Text('Bugünkü Durum', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => context.go(routeLabSettings),
|
||||
icon: const Icon(Icons.settings_outlined, color: AppColors.textSecondary, size: 22),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return SliverAppBar(
|
||||
pinned: true,
|
||||
expandedHeight: 148,
|
||||
backgroundColor: AppColors.primary,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
systemOverlayStyle: SystemUiOverlayStyle.light,
|
||||
centerTitle: false,
|
||||
leadingWidth: 60,
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 8, bottom: 8),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Center(child: ToothLogo(size: 20, color: Colors.white)),
|
||||
),
|
||||
),
|
||||
titleSpacing: 8,
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('DLS', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 1.5)),
|
||||
Text(companyName, style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w700), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => context.go(routeLabSettings),
|
||||
icon: const Icon(Icons.settings_outlined, color: Colors.white, size: 22),
|
||||
),
|
||||
],
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.pin,
|
||||
background: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.primary, AppColors.accent],
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text('Genel Bakış', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 12, fontWeight: FontWeight.w500, letterSpacing: 0.5)),
|
||||
const SizedBox(height: 4),
|
||||
const Text('Bugünkü Durum', style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.w800, letterSpacing: -0.5)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatsRow extends StatelessWidget {
|
||||
const _StatsRow({
|
||||
required this.pending,
|
||||
required this.inProgress,
|
||||
required this.sent,
|
||||
required this.delivered,
|
||||
});
|
||||
final int pending;
|
||||
final int inProgress;
|
||||
final int sent;
|
||||
final int delivered;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isWideDesktop = MediaQuery.sizeOf(context).width >= AppLayout.wideBreakpoint;
|
||||
|
||||
final pendingCard = _StatCard(label: 'Bekleyen', value: '$pending', icon: Icons.hourglass_top_rounded, color: AppColors.pending, bgColor: AppColors.pendingBg)
|
||||
.animate().fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
|
||||
final inProgressCard = _StatCard(label: 'Devam Eden', value: '$inProgress', icon: Icons.autorenew_rounded, color: AppColors.inProgress, bgColor: AppColors.inProgressBg)
|
||||
.animate(delay: 80.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
|
||||
|
||||
if (isWideDesktop) {
|
||||
final sentCard = _StatCard(label: 'Klinik\'te', value: '$sent', icon: Icons.local_hospital_outlined, color: AppColors.accent, bgColor: AppColors.inProgressBg)
|
||||
.animate(delay: 120.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
|
||||
final deliveredCard = _StatCard(label: 'Tamamlanan', value: '$delivered', icon: Icons.task_alt, color: AppColors.success, bgColor: AppColors.successBg)
|
||||
.animate(delay: 160.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: pendingCard),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: inProgressCard),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: sentCard),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: deliveredCard),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: pendingCard),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: inProgressCard),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatCard extends StatelessWidget {
|
||||
const _StatCard({required this.label, required this.value, required this.icon, required this.color, required this.bgColor});
|
||||
final String label;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final Color bgColor;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 12, offset: const Offset(0, 4))],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44, height: 44,
|
||||
decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(icon, color: color, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(value, style: TextStyle(fontSize: 28, fontWeight: FontWeight.w800, color: color, height: 1)),
|
||||
const SizedBox(height: 3),
|
||||
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary, fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AcceptAllBanner extends StatelessWidget {
|
||||
const _AcceptAllBanner({required this.count, required this.loading, required this.onTap});
|
||||
final int count;
|
||||
final bool loading;
|
||||
final VoidCallback onTap;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: AppColors.pendingBg,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: loading ? null : onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: AppColors.pending.withValues(alpha: 0.35))),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38, height: 38,
|
||||
decoration: BoxDecoration(color: AppColors.pending.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10)),
|
||||
child: const Icon(Icons.notifications_active_outlined, color: AppColors.pending, size: 18),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('$count yeni iş bekliyor', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
const Text('Tümünü hızlıca kabul et', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
loading
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.pending))
|
||||
: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(color: AppColors.pending, borderRadius: BorderRadius.circular(8)),
|
||||
child: const Text('Kabul Et', style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JobCard extends StatelessWidget {
|
||||
const _JobCard({required this.job});
|
||||
final Job job;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final due = job.dueDate;
|
||||
final isOverdue = due != null && due.isBefore(DateTime.now());
|
||||
final dueText = due != null ? '${due.day.toString().padLeft(2, '0')}.${due.month.toString().padLeft(2, '0')}.${due.year}' : null;
|
||||
return Semantics(
|
||||
label: job.patientCode,
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
child: Material(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: () => context.push('/lab/jobs/${job.id}'),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: AppColors.border)),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 46, height: 46,
|
||||
decoration: BoxDecoration(color: AppColors.inProgressBg, borderRadius: BorderRadius.circular(12)),
|
||||
child: Center(child: Text('${job.memberCount}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: AppColors.inProgress))),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(job.patientCode, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 2),
|
||||
Text(job.clinicName ?? 'Klinik', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
children: [
|
||||
_Tag(label: job.prostheticType.label, color: AppColors.inProgress, bg: AppColors.inProgressBg),
|
||||
if (job.currentStep != null) _Tag(label: job.currentStep!.label, color: AppColors.success, bg: AppColors.successBg),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (dueText != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Icon(Icons.calendar_today_outlined, size: 13, color: isOverdue ? AppColors.cancelled : AppColors.textMuted),
|
||||
const SizedBox(height: 3),
|
||||
Text(dueText, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: isOverdue ? AppColors.cancelled : AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Tag extends StatelessWidget {
|
||||
const _Tag({required this.label, required this.color, required this.bg});
|
||||
final String label;
|
||||
final Color color;
|
||||
final Color bg;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
|
||||
child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _EmptySection extends StatelessWidget {
|
||||
const _EmptySection({required this.message});
|
||||
final String message;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle_outline_rounded, color: AppColors.textSecondary.withValues(alpha: 0.5), size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Text(message, style: TextStyle(fontSize: 14, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorBody extends StatelessWidget {
|
||||
const _ErrorBody({required this.onRetry});
|
||||
final VoidCallback onRetry;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64, height: 64,
|
||||
decoration: BoxDecoration(color: AppColors.cancelledBg, borderRadius: BorderRadius.circular(16)),
|
||||
child: const Icon(Icons.wifi_off_rounded, color: AppColors.cancelled, size: 30),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Bağlantı hatası', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(onPressed: onRetry, icon: const Icon(Icons.refresh_rounded, size: 18), label: const Text('Tekrar Dene')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DashboardSkeleton extends StatelessWidget {
|
||||
const _DashboardSkeleton({required this.companyName, required this.hPad});
|
||||
final String companyName;
|
||||
final double hPad;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
_DashboardHeader(companyName: companyName),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(hPad, 16, hPad, 0),
|
||||
sliver: const SliverToBoxAdapter(
|
||||
child: Row(children: [
|
||||
Expanded(child: _ShimmerBox(height: 84, radius: 16)),
|
||||
SizedBox(width: 12),
|
||||
Expanded(child: _ShimmerBox(height: 84, radius: 16)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(hPad, 8, hPad, 0),
|
||||
sliver: SliverList.builder(
|
||||
itemCount: 4,
|
||||
itemBuilder: (_, i) => const Padding(padding: EdgeInsets.only(bottom: 10), child: _ShimmerBox(height: 92, radius: 14)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShimmerBox extends StatefulWidget {
|
||||
const _ShimmerBox({required this.height, required this.radius});
|
||||
final double height;
|
||||
final double radius;
|
||||
@override
|
||||
State<_ShimmerBox> createState() => _ShimmerBoxState();
|
||||
}
|
||||
|
||||
class _ShimmerBoxState extends State<_ShimmerBox> with SingleTickerProviderStateMixin {
|
||||
late AnimationController _ctrl;
|
||||
late Animation<double> _anim;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1100))..repeat(reverse: true);
|
||||
_anim = CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut);
|
||||
}
|
||||
@override
|
||||
void dispose() { _ctrl.dispose(); super.dispose(); }
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _anim,
|
||||
builder: (_, __) => Container(
|
||||
height: widget.height,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(widget.radius), color: Color.lerp(const Color(0xFFE2E8F0), const Color(0xFFF1F5F9), _anim.value)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Monthly Report ──────────────────────────────────────────────────────────
|
||||
|
||||
class _MonthlyReportSection extends StatelessWidget {
|
||||
const _MonthlyReportSection({required this.data});
|
||||
final _DashboardData data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pct = data.changePercent;
|
||||
final isUp = pct >= 0;
|
||||
final pctStr = '${isUp ? '+' : ''}${pct.toStringAsFixed(0)}%';
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.bar_chart_rounded, size: 18, color: AppColors.accent),
|
||||
const SizedBox(width: 6),
|
||||
Text('Aylık Rapor', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MonthStat(
|
||||
label: 'Bu Ay',
|
||||
value: data.thisMonthDelivered,
|
||||
highlighted: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _MonthStat(
|
||||
label: 'Geçen Ay',
|
||||
value: data.lastMonthDelivered,
|
||||
highlighted: false,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isUp ? AppColors.successBg : AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isUp ? Icons.trending_up_rounded : Icons.trending_down_rounded,
|
||||
size: 16,
|
||||
color: isUp ? AppColors.success : AppColors.cancelled,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
pctStr,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isUp ? AppColors.success : AppColors.cancelled,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MonthStat extends StatelessWidget {
|
||||
const _MonthStat({required this.label, required this.value, required this.highlighted});
|
||||
final String label;
|
||||
final int value;
|
||||
final bool highlighted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: highlighted ? AppColors.accent.withValues(alpha: 0.06) : AppColors.background,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: highlighted ? Border.all(color: AppColors.accent.withValues(alpha: 0.2)) : null,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: TextStyle(fontSize: 11, color: AppColors.textSecondary, fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'$value iş',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: highlighted ? AppColors.accent : AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gamification Row ─────────────────────────────────────────────────────────
|
||||
|
||||
const _monthlyGoal = 50;
|
||||
|
||||
class _GamificationRow extends StatelessWidget {
|
||||
const _GamificationRow({required this.data});
|
||||
final _DashboardData data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progress = (data.thisMonthDelivered / _monthlyGoal).clamp(0.0, 1.0);
|
||||
final remaining = (_monthlyGoal - data.thisMonthDelivered).clamp(0, _monthlyGoal);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text('🏆', style: TextStyle(fontSize: 16)),
|
||||
const SizedBox(width: 6),
|
||||
Text('Aylık Hedef', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'${data.points} puan',
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 8,
|
||||
backgroundColor: AppColors.background,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
progress >= 1.0 ? AppColors.success : AppColors.accent,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'${data.thisMonthDelivered} / $_monthlyGoal iş teslim edildi',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
||||
),
|
||||
Text(
|
||||
progress >= 1.0 ? 'Hedef tamamlandı!' : '$remaining iş kaldı',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: progress >= 1.0 ? AppColors.success : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data Model ───────────────────────────────────────────────────────────────
|
||||
|
||||
class _DashboardData {
|
||||
final List<Job> pendingJobs;
|
||||
final List<Job> inProgressJobs;
|
||||
final List<Job> atLabJobs;
|
||||
final List<Job> atClinicJobs;
|
||||
final int sentCount;
|
||||
final int deliveredCount;
|
||||
final int thisMonthDelivered;
|
||||
final int lastMonthDelivered;
|
||||
const _DashboardData({
|
||||
required this.pendingJobs,
|
||||
required this.inProgressJobs,
|
||||
required this.atLabJobs,
|
||||
required this.atClinicJobs,
|
||||
required this.sentCount,
|
||||
required this.deliveredCount,
|
||||
required this.thisMonthDelivered,
|
||||
required this.lastMonthDelivered,
|
||||
});
|
||||
|
||||
int get points => thisMonthDelivered * 10;
|
||||
double get changePercent => lastMonthDelivered == 0
|
||||
? (thisMonthDelivered > 0 ? 100 : 0)
|
||||
: (thisMonthDelivered - lastMonthDelivered) / lastMonthDelivered * 100;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../models/clinic_discount.dart';
|
||||
|
||||
class DiscountRepository {
|
||||
DiscountRepository._();
|
||||
static final instance = DiscountRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<List<ClinicDiscount>> listDiscounts(String labTenantId) async {
|
||||
final result = await _pb.collection('clinic_discounts').getList(
|
||||
filter: 'lab_tenant_id = "$labTenantId"',
|
||||
expand: 'clinic_tenant_id',
|
||||
perPage: 200,
|
||||
);
|
||||
final list = result.items
|
||||
.map((r) => ClinicDiscount.fromJson(r.toJson()))
|
||||
.toList();
|
||||
list.sort((a, b) {
|
||||
// Active first, then by clinic name
|
||||
if (a.isActive != b.isActive) return a.isActive ? -1 : 1;
|
||||
final ca = a.clinicName ?? '';
|
||||
final cb = b.clinicName ?? '';
|
||||
return ca.compareTo(cb);
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
Future<ClinicDiscount> createDiscount({
|
||||
required String labTenantId,
|
||||
String? clinicTenantId,
|
||||
String? prostheticType,
|
||||
required DiscountType discountType,
|
||||
required double discountValue,
|
||||
int minQuantity = 0,
|
||||
bool isActive = true,
|
||||
String? notes,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
'lab_tenant_id': labTenantId,
|
||||
'discount_type': discountType.value,
|
||||
'discount_value': discountValue,
|
||||
'is_active': isActive,
|
||||
};
|
||||
if (clinicTenantId != null && clinicTenantId.isNotEmpty) {
|
||||
body['clinic_tenant_id'] = clinicTenantId;
|
||||
}
|
||||
if (prostheticType != null && prostheticType.isNotEmpty) {
|
||||
body['prosthetic_type'] = prostheticType;
|
||||
}
|
||||
if (minQuantity > 0) body['min_quantity'] = minQuantity;
|
||||
if (notes != null && notes.isNotEmpty) body['notes'] = notes;
|
||||
|
||||
final record = await _pb.collection('clinic_discounts').create(
|
||||
body: body,
|
||||
expand: 'clinic_tenant_id',
|
||||
);
|
||||
return ClinicDiscount.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<ClinicDiscount> updateDiscount(
|
||||
String id, {
|
||||
String? clinicTenantId,
|
||||
String? prostheticType,
|
||||
DiscountType? discountType,
|
||||
double? discountValue,
|
||||
int? minQuantity,
|
||||
bool? isActive,
|
||||
String? notes,
|
||||
}) async {
|
||||
final body = <String, dynamic>{};
|
||||
if (clinicTenantId != null) body['clinic_tenant_id'] = clinicTenantId.isEmpty ? null : clinicTenantId;
|
||||
if (prostheticType != null) body['prosthetic_type'] = prostheticType.isEmpty ? '' : prostheticType;
|
||||
if (discountType != null) body['discount_type'] = discountType.value;
|
||||
if (discountValue != null) body['discount_value'] = discountValue;
|
||||
if (minQuantity != null) body['min_quantity'] = minQuantity;
|
||||
if (isActive != null) body['is_active'] = isActive;
|
||||
if (notes != null) body['notes'] = notes;
|
||||
|
||||
final record = await _pb.collection('clinic_discounts').update(
|
||||
id,
|
||||
body: body,
|
||||
expand: 'clinic_tenant_id',
|
||||
);
|
||||
return ClinicDiscount.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<void> deleteDiscount(String id) async {
|
||||
await _pb.collection('clinic_discounts').delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,940 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/gradient_app_bar.dart';
|
||||
import '../../../models/clinic_discount.dart';
|
||||
import 'discount_repository.dart';
|
||||
|
||||
// Simple local record for clinic picker
|
||||
class _ClinicOption {
|
||||
const _ClinicOption({required this.id, required this.name});
|
||||
final String id;
|
||||
final String name;
|
||||
}
|
||||
|
||||
class DiscountsScreen extends ConsumerStatefulWidget {
|
||||
const DiscountsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DiscountsScreen> createState() => _DiscountsScreenState();
|
||||
}
|
||||
|
||||
class _DiscountsScreenState extends ConsumerState<DiscountsScreen> {
|
||||
late Future<List<ClinicDiscount>> _future;
|
||||
String _searchQuery = '';
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_future = DiscountRepository.instance.listDiscounts(tenantId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showSheet({ClinicDiscount? existing}) async {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
final result = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _DiscountSheet(
|
||||
labTenantId: tenantId,
|
||||
existing: existing,
|
||||
),
|
||||
);
|
||||
if (result == true) _load();
|
||||
}
|
||||
|
||||
Future<void> _delete(ClinicDiscount discount) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('İndirimi Sil'),
|
||||
content: Text(
|
||||
'${discount.clinicName ?? 'Tüm Klinikler'} — ${discount.displayValue} indirimi silinsin mi?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('İptal')),
|
||||
FilledButton(
|
||||
style:
|
||||
FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Sil'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
await DiscountRepository.instance.deleteDiscount(discount.id);
|
||||
_load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Hata: $e'),
|
||||
backgroundColor: AppColors.cancelled),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleActive(ClinicDiscount discount) async {
|
||||
try {
|
||||
await DiscountRepository.instance
|
||||
.updateDiscount(discount.id, isActive: !discount.isActive);
|
||||
_load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Hata: $e'),
|
||||
backgroundColor: AppColors.cancelled),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: GradientAppBar(
|
||||
title: 'İndirimler',
|
||||
category: 'LABORATUVAR',
|
||||
searchController: _searchController,
|
||||
onSearchChanged: (v) => setState(() => _searchQuery = v),
|
||||
searchHint: 'Klinik veya ürün tipi ara...',
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _showSheet(),
|
||||
backgroundColor: AppColors.accent,
|
||||
foregroundColor: Colors.white,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Yeni İndirim'),
|
||||
),
|
||||
body: FutureBuilder<List<ClinicDiscount>>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent));
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.wifi_off_rounded,
|
||||
color: AppColors.cancelled, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
Text('Hata: ${snap.error}',
|
||||
style:
|
||||
const TextStyle(color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 16),
|
||||
label: const Text('Tekrar Dene')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final allDiscounts = snap.data!;
|
||||
final q = _searchQuery.toLowerCase().trim();
|
||||
final discounts = q.isEmpty
|
||||
? allDiscounts
|
||||
: allDiscounts
|
||||
.where((d) =>
|
||||
(d.clinicName ?? 'tüm klinikler')
|
||||
.toLowerCase()
|
||||
.contains(q) ||
|
||||
d.prostheticLabel.toLowerCase().contains(q))
|
||||
.toList();
|
||||
|
||||
if (allDiscounts.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(Icons.discount_outlined,
|
||||
size: 32, color: AppColors.inProgress),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Henüz indirim tanımlanmadı',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Klinik ve ürün bazlı özel indirimler ekleyin.',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary, fontSize: 13),
|
||||
textAlign: TextAlign.center),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _showSheet(),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('İlk İndirimi Ekle'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (discounts.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Sonuç bulunamadı',
|
||||
style: TextStyle(color: AppColors.textSecondary)),
|
||||
);
|
||||
}
|
||||
|
||||
final active = discounts.where((d) => d.isActive).toList();
|
||||
final inactive = discounts.where((d) => !d.isActive).toList();
|
||||
|
||||
return RefreshIndicator(
|
||||
color: AppColors.accent,
|
||||
onRefresh: () async => _load(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
|
||||
children: [
|
||||
if (active.isNotEmpty) ...[
|
||||
_GroupHeader('Aktif (${active.length})'),
|
||||
for (final d in active)
|
||||
_DiscountCard(
|
||||
discount: d,
|
||||
onEdit: () => _showSheet(existing: d),
|
||||
onDelete: () => _delete(d),
|
||||
onToggle: () => _toggleActive(d),
|
||||
),
|
||||
],
|
||||
if (inactive.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
_GroupHeader('Pasif (${inactive.length})'),
|
||||
for (final d in inactive)
|
||||
_DiscountCard(
|
||||
discount: d,
|
||||
onEdit: () => _showSheet(existing: d),
|
||||
onDelete: () => _delete(d),
|
||||
onToggle: () => _toggleActive(d),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Group header ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _GroupHeader extends StatelessWidget {
|
||||
const _GroupHeader(this.text);
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8, top: 4),
|
||||
child: Text(text,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textMuted,
|
||||
letterSpacing: 0.5)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Discount card ─────────────────────────────────────────────────────────────
|
||||
|
||||
class _DiscountCard extends StatelessWidget {
|
||||
const _DiscountCard({
|
||||
required this.discount,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
final ClinicDiscount discount;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onToggle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final d = discount;
|
||||
final isActive = d.isActive;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Material(
|
||||
color: isActive ? AppColors.surface : AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: onEdit,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isActive ? AppColors.border : AppColors.muted),
|
||||
boxShadow: isActive
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.03),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2))
|
||||
]
|
||||
: [],
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 4,
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
isActive ? AppColors.success : AppColors.border,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(14),
|
||||
bottomLeft: Radius.circular(14),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 4, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? AppColors.successBg
|
||||
: AppColors.background,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${d.displayValue} İndirim',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: isActive
|
||||
? AppColors.success
|
||||
: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (d.minQuantity > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 7, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.pendingBg,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'≥${d.minQuantity} adet',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.pending),
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
Transform.scale(
|
||||
scale: 0.8,
|
||||
child: Switch(
|
||||
value: isActive,
|
||||
onChanged: (_) => onToggle(),
|
||||
activeColor: AppColors.success,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
_Tag(
|
||||
icon: Icons.local_hospital_outlined,
|
||||
label: d.appliesToAll
|
||||
? 'Tüm Klinikler'
|
||||
: (d.clinicName ?? 'Klinik'),
|
||||
color: AppColors.inProgress,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_Tag(
|
||||
icon: Icons.science_outlined,
|
||||
label: d.prostheticLabel,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (d.notes != null && d.notes!.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(d.notes!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textMuted),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onDelete,
|
||||
icon: const Icon(Icons.delete_outline_rounded,
|
||||
size: 18, color: AppColors.cancelled),
|
||||
tooltip: 'Sil',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Tag extends StatelessWidget {
|
||||
const _Tag(
|
||||
{required this.icon, required this.label, required this.color});
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 11, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Discount sheet ────────────────────────────────────────────────────────────
|
||||
|
||||
class _DiscountSheet extends StatefulWidget {
|
||||
const _DiscountSheet({required this.labTenantId, this.existing});
|
||||
final String labTenantId;
|
||||
final ClinicDiscount? existing;
|
||||
|
||||
@override
|
||||
State<_DiscountSheet> createState() => _DiscountSheetState();
|
||||
}
|
||||
|
||||
class _DiscountSheetState extends State<_DiscountSheet> {
|
||||
final _valueCtrl = TextEditingController();
|
||||
final _minQtyCtrl = TextEditingController();
|
||||
final _notesCtrl = TextEditingController();
|
||||
|
||||
DiscountType _discountType = DiscountType.percentage;
|
||||
String? _selectedClinicId;
|
||||
String? _selectedType;
|
||||
bool _isActive = true;
|
||||
bool _saving = false;
|
||||
|
||||
List<_ClinicOption>? _clinics;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final e = widget.existing;
|
||||
if (e != null) {
|
||||
_valueCtrl.text = e.discountValue.toStringAsFixed(
|
||||
e.discountValue % 1 == 0 ? 0 : 2);
|
||||
_minQtyCtrl.text =
|
||||
e.minQuantity > 0 ? e.minQuantity.toString() : '';
|
||||
_notesCtrl.text = e.notes ?? '';
|
||||
_discountType = e.discountType;
|
||||
_selectedClinicId = e.clinicTenantId;
|
||||
_selectedType = e.prostheticType;
|
||||
_isActive = e.isActive;
|
||||
}
|
||||
_loadClinics();
|
||||
}
|
||||
|
||||
Future<void> _loadClinics() async {
|
||||
try {
|
||||
final pb = PocketBaseClient.instance.pb;
|
||||
final result = await pb.collection('tenant_connections').getList(
|
||||
filter:
|
||||
'lab_tenant_id = "${widget.labTenantId}" && status = "approved"',
|
||||
expand: 'clinic_tenant_id',
|
||||
perPage: 100,
|
||||
);
|
||||
final clinics = result.items.map((r) {
|
||||
final j = r.toJson();
|
||||
final expand = j['expand'] as Map<String, dynamic>?;
|
||||
final clinic =
|
||||
expand?['clinic_tenant_id'] as Map<String, dynamic>?;
|
||||
return _ClinicOption(
|
||||
id: j['clinic_tenant_id'] as String? ?? '',
|
||||
name: clinic?['company_name'] as String? ?? 'Klinik',
|
||||
);
|
||||
}).where((c) => c.id.isNotEmpty).toList();
|
||||
if (mounted) setState(() => _clinics = clinics);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _clinics = []);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_valueCtrl.dispose();
|
||||
_minQtyCtrl.dispose();
|
||||
_notesCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final valueStr = _valueCtrl.text.trim().replaceAll(',', '.');
|
||||
final value = double.tryParse(valueStr);
|
||||
if (value == null || value <= 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Geçerli bir indirim değeri girin.'),
|
||||
backgroundColor: AppColors.cancelled),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_discountType == DiscountType.percentage && value > 100) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Yüzde indirim 100'ü geçemez."),
|
||||
backgroundColor: AppColors.cancelled),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final minQty = int.tryParse(_minQtyCtrl.text.trim()) ?? 0;
|
||||
setState(() => _saving = true);
|
||||
final navigator = Navigator.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
if (widget.existing != null) {
|
||||
await DiscountRepository.instance.updateDiscount(
|
||||
widget.existing!.id,
|
||||
clinicTenantId: _selectedClinicId ?? '',
|
||||
prostheticType: _selectedType ?? '',
|
||||
discountType: _discountType,
|
||||
discountValue: value,
|
||||
minQuantity: minQty,
|
||||
isActive: _isActive,
|
||||
notes: _notesCtrl.text.trim(),
|
||||
);
|
||||
} else {
|
||||
await DiscountRepository.instance.createDiscount(
|
||||
labTenantId: widget.labTenantId,
|
||||
clinicTenantId: _selectedClinicId,
|
||||
prostheticType: _selectedType,
|
||||
discountType: _discountType,
|
||||
discountValue: value,
|
||||
minQuantity: minQty,
|
||||
isActive: _isActive,
|
||||
notes: _notesCtrl.text.trim(),
|
||||
);
|
||||
}
|
||||
navigator.pop(true);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Hata: $e'),
|
||||
backgroundColor: AppColors.cancelled),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static const _prostheticTypes = [
|
||||
('', 'Tüm Türler'),
|
||||
('metal_porselen', 'Metal Porselen'),
|
||||
('zirkonyum', 'Zirkonyum'),
|
||||
('implant_ustu_zirkonyum', 'İmplant Üstü Zirkonyum'),
|
||||
('gecici', 'Geçici'),
|
||||
('e_max', 'E-Max'),
|
||||
('tam_protez', 'Tam Protez'),
|
||||
('parsiyel', 'Parsiyel Protez'),
|
||||
('diger', 'Diğer'),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottom = MediaQuery.paddingOf(context).bottom;
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
padding: EdgeInsets.only(bottom: bottom),
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 20,
|
||||
bottom: MediaQuery.viewInsetsOf(context).bottom + 20,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.border,
|
||||
borderRadius: BorderRadius.circular(2)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.existing != null
|
||||
? 'İndirimi Düzenle'
|
||||
: 'Yeni İndirim',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
const Text('İndirim Türü',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TypeButton(
|
||||
label: 'Yüzde (%)',
|
||||
icon: Icons.percent_rounded,
|
||||
selected: _discountType == DiscountType.percentage,
|
||||
onTap: () => setState(
|
||||
() => _discountType = DiscountType.percentage),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _TypeButton(
|
||||
label: 'Sabit Tutar',
|
||||
icon: Icons.currency_lira_rounded,
|
||||
selected: _discountType == DiscountType.fixed,
|
||||
onTap: () =>
|
||||
setState(() => _discountType = DiscountType.fixed),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
const Text('İndirim Değeri',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _valueCtrl,
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
hintText: _discountType == DiscountType.percentage
|
||||
? 'Örn: 10'
|
||||
: 'Örn: 150',
|
||||
suffixText:
|
||||
_discountType == DiscountType.percentage ? '%' : 'TL',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
const Text('Klinik',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 8),
|
||||
_ClinicDropdown(
|
||||
selectedId: _selectedClinicId,
|
||||
clinics: _clinics,
|
||||
onChanged: (id, _) => setState(() {
|
||||
_selectedClinicId = id;
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
const Text('Ürün Tipi',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedType ?? '',
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 12),
|
||||
),
|
||||
items: _prostheticTypes
|
||||
.map((t) =>
|
||||
DropdownMenuItem(value: t.$1, child: Text(t.$2)))
|
||||
.toList(),
|
||||
onChanged: (v) =>
|
||||
setState(() => _selectedType = v == '' ? null : v),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
const Text('Minimum Sipariş Adedi (İsteğe Bağlı)',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Aylık bu adede ulaşılınca indirim devreye girer. 0 = koşulsuz.',
|
||||
style:
|
||||
TextStyle(fontSize: 11, color: AppColors.textMuted)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _minQtyCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
hintText: '0',
|
||||
suffixText: 'adet',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
const Text('Not (İsteğe Bağlı)',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _notesCtrl,
|
||||
maxLines: 2,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Açıklama...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Aktif',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary)),
|
||||
Text('Pasif indirimler uygulanmaz.',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: AppColors.textMuted)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: _isActive,
|
||||
onChanged: (v) => setState(() => _isActive = v),
|
||||
activeColor: AppColors.success,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: FilledButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.accent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white))
|
||||
: Text(
|
||||
widget.existing != null ? 'Güncelle' : 'Kaydet',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TypeButton extends StatelessWidget {
|
||||
const _TypeButton({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? AppColors.accent : AppColors.background,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: selected ? AppColors.accent : AppColors.border),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon,
|
||||
size: 16,
|
||||
color: selected ? Colors.white : AppColors.textSecondary),
|
||||
const SizedBox(width: 6),
|
||||
Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: selected
|
||||
? Colors.white
|
||||
: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ClinicDropdown extends StatelessWidget {
|
||||
const _ClinicDropdown({
|
||||
required this.selectedId,
|
||||
required this.clinics,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final String? selectedId;
|
||||
final List<_ClinicOption>? clinics;
|
||||
final void Function(String? id, String? name) onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (clinics == null) {
|
||||
return Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.border),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))),
|
||||
);
|
||||
}
|
||||
|
||||
final items = <DropdownMenuItem<String>>[
|
||||
const DropdownMenuItem(value: '', child: Text('Tüm Klinikler')),
|
||||
for (final c in clinics!)
|
||||
DropdownMenuItem(value: c.id, child: Text(c.name)),
|
||||
];
|
||||
|
||||
return DropdownButtonFormField<String>(
|
||||
value: selectedId ?? '',
|
||||
decoration: InputDecoration(
|
||||
border:
|
||||
OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
),
|
||||
items: items,
|
||||
onChanged: (v) {
|
||||
if (v == null || v.isEmpty) {
|
||||
onChanged(null, null);
|
||||
} else {
|
||||
final clinic = clinics!.firstWhere((c) => c.id == v);
|
||||
onChanged(v, clinic.name);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../models/finance_entry.dart';
|
||||
|
||||
class LabFinanceRepository {
|
||||
LabFinanceRepository._();
|
||||
static final instance = LabFinanceRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<List<FinanceEntry>> listEntries(
|
||||
String tenantId, {
|
||||
String? status,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) async {
|
||||
final filterParts = ['tenant_id = "$tenantId"', 'type = "receivable"'];
|
||||
if (status != null) filterParts.add('status = "$status"');
|
||||
|
||||
final result = await _pb.collection('finance_entries').getList(
|
||||
page: page,
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
expand: 'job_id',
|
||||
);
|
||||
return (result.items.map((r) => FinanceEntry.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? '')));
|
||||
}
|
||||
|
||||
Future<Map<String, double>> summary(String tenantId) async {
|
||||
final all = await listEntries(tenantId, limit: 200);
|
||||
double pending = 0, paid = 0;
|
||||
for (final e in all) {
|
||||
if (e.status == FinanceStatus.pending) {
|
||||
pending += e.amount;
|
||||
} else {
|
||||
paid += e.amount;
|
||||
}
|
||||
}
|
||||
return {'pending': pending, 'paid': paid};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/providers/locale_provider.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/utils/currency_formatter.dart';
|
||||
import '../../../core/widgets/gradient_app_bar.dart';
|
||||
import '../../../core/widgets/pill_tabs.dart';
|
||||
import '../../../models/finance_entry.dart';
|
||||
import 'lab_finance_repository.dart';
|
||||
|
||||
enum _FinanceSort { newestFirst, byAmountDesc, byAmountAsc }
|
||||
|
||||
class LabFinanceScreen extends ConsumerStatefulWidget {
|
||||
const LabFinanceScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LabFinanceScreen> createState() => _LabFinanceScreenState();
|
||||
}
|
||||
|
||||
class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
late Future<_FinanceData> _future;
|
||||
_FinanceSort _sort = _FinanceSort.newestFirst;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_tabController.addListener(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_future = Future.wait([
|
||||
LabFinanceRepository.instance.listEntries(tenantId, status: 'pending'),
|
||||
LabFinanceRepository.instance.listEntries(tenantId, status: 'paid'),
|
||||
LabFinanceRepository.instance.summary(tenantId),
|
||||
]).then((results) => _FinanceData(
|
||||
pending: results[0] as List<FinanceEntry>,
|
||||
paid: results[1] as List<FinanceEntry>,
|
||||
summary: results[2] as Map<String, double>,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showSortOptions() async {
|
||||
final s = ref.read(stringsProvider);
|
||||
final result = await showSortSheet(
|
||||
context,
|
||||
title: s.sort,
|
||||
options: [s.sortNewest, s.sortAmountDesc, s.sortAmountAsc],
|
||||
current: _sort.index,
|
||||
);
|
||||
if (result != null) {
|
||||
setState(() => _sort = _FinanceSort.values[result]);
|
||||
}
|
||||
}
|
||||
|
||||
List<FinanceEntry> _sorted(List<FinanceEntry> entries) {
|
||||
final list = List<FinanceEntry>.from(entries);
|
||||
switch (_sort) {
|
||||
case _FinanceSort.newestFirst:
|
||||
list.sort((a, b) {
|
||||
final da = a.dateCreated != null ? DateTime.tryParse(a.dateCreated!) : null;
|
||||
final db = b.dateCreated != null ? DateTime.tryParse(b.dateCreated!) : null;
|
||||
if (da == null && db == null) return 0;
|
||||
if (da == null) return 1;
|
||||
if (db == null) return -1;
|
||||
return db.compareTo(da);
|
||||
});
|
||||
case _FinanceSort.byAmountDesc:
|
||||
list.sort((a, b) => b.amount.compareTo(a.amount));
|
||||
case _FinanceSort.byAmountAsc:
|
||||
list.sort((a, b) => a.amount.compareTo(b.amount));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
String _formatDate(String? raw) {
|
||||
if (raw == null) return '';
|
||||
try {
|
||||
final dt = DateTime.parse(raw);
|
||||
return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isSortActive = _sort != _FinanceSort.newestFirst;
|
||||
final s = ref.watch(stringsProvider);
|
||||
final currencyCode =
|
||||
ref.watch(authProvider).activeTenant?.tenant.defaultCurrency ?? 'TRY';
|
||||
String formatAmount(double amount) =>
|
||||
CurrencyFormatter.format(amount, currencyCode);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: GradientAppBar(
|
||||
title: s.finance,
|
||||
category: s.laboratoryCategory,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _showSortOptions,
|
||||
tooltip: 'Sırala',
|
||||
icon: Badge(
|
||||
isLabelVisible: isSortActive,
|
||||
smallSize: 8,
|
||||
backgroundColor: AppColors.accent,
|
||||
child: const Icon(Icons.sort_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
color: AppColors.accent,
|
||||
onRefresh: () async => _load(),
|
||||
child: FutureBuilder<_FinanceData>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent));
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
child: const Icon(Icons.wifi_off_rounded,
|
||||
color: AppColors.cancelled, size: 30),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Hata: ${snap.error}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('Tekrar Dene'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final data = snap.data!;
|
||||
final pendingTotal = data.summary['pending'] ?? 0.0;
|
||||
final paidTotal = data.summary['paid'] ?? 0.0;
|
||||
final pending = _sorted(data.pending);
|
||||
final paid = _sorted(data.paid);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SummaryCard(
|
||||
label: s.pendingReceivable,
|
||||
amount: formatAmount(pendingTotal),
|
||||
color: AppColors.pending,
|
||||
bgColor: AppColors.pendingBg,
|
||||
icon: Icons.hourglass_empty_rounded,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SummaryCard(
|
||||
label: s.collected,
|
||||
amount: formatAmount(paidTotal),
|
||||
color: AppColors.success,
|
||||
bgColor: AppColors.successBg,
|
||||
icon: Icons.check_circle_outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PillTabs(
|
||||
tabs: [s.pending, s.collected],
|
||||
selected: _tabController.index,
|
||||
onSelect: (i) => _tabController.animateTo(i),
|
||||
counts: [pending.length, paid.length],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_EntriesList(
|
||||
entries: pending,
|
||||
emptyMessage: s.noPendingEntries,
|
||||
emptyIcon: Icons.hourglass_empty_rounded,
|
||||
formatDate: _formatDate,
|
||||
formatAmount: formatAmount,
|
||||
),
|
||||
_EntriesList(
|
||||
entries: paid,
|
||||
emptyMessage: s.noPaidEntries,
|
||||
emptyIcon: Icons.check_circle_outline,
|
||||
formatDate: _formatDate,
|
||||
formatAmount: formatAmount,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FinanceData {
|
||||
const _FinanceData({
|
||||
required this.pending,
|
||||
required this.paid,
|
||||
required this.summary,
|
||||
});
|
||||
|
||||
final List<FinanceEntry> pending;
|
||||
final List<FinanceEntry> paid;
|
||||
final Map<String, double> summary;
|
||||
}
|
||||
|
||||
class _SummaryCard extends StatelessWidget {
|
||||
const _SummaryCard({
|
||||
required this.label,
|
||||
required this.amount,
|
||||
required this.color,
|
||||
required this.bgColor,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String amount;
|
||||
final Color color;
|
||||
final Color bgColor;
|
||||
final IconData icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.06),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor, borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(icon, color: color, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
amount,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: color,
|
||||
height: 1),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EntriesList extends StatelessWidget {
|
||||
const _EntriesList({
|
||||
required this.entries,
|
||||
required this.emptyMessage,
|
||||
required this.emptyIcon,
|
||||
required this.formatDate,
|
||||
required this.formatAmount,
|
||||
});
|
||||
|
||||
final List<FinanceEntry> entries;
|
||||
final String emptyMessage;
|
||||
final IconData emptyIcon;
|
||||
final String Function(String?) formatDate;
|
||||
final String Function(double) formatAmount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (entries.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(20)),
|
||||
child: Icon(emptyIcon, size: 32, color: AppColors.inProgress),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(emptyMessage,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
itemCount: entries.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final entry = entries[i];
|
||||
final isPending = entry.status == FinanceStatus.pending;
|
||||
final statusColor = isPending ? AppColors.pending : AppColors.success;
|
||||
final statusBg = isPending ? AppColors.pendingBg : AppColors.successBg;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2))
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: statusBg,
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(
|
||||
isPending
|
||||
? Icons.hourglass_empty_rounded
|
||||
: Icons.check_circle_outline,
|
||||
color: statusColor,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
entry.counterpartyName ?? 'Klinik',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
if (entry.patientCode != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Protokol: ${entry.patientCode}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
if (entry.dateCreated != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
formatDate(entry.dateCreated),
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppColors.textMuted),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
formatAmount(entry.amount),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: statusColor,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: statusBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
entry.status.label,
|
||||
style: TextStyle(
|
||||
color: statusColor,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,896 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/services/realtime_service.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/gradient_app_bar.dart';
|
||||
import '../../../core/widgets/pill_tabs.dart';
|
||||
import '../../../models/job.dart';
|
||||
import 'lab_jobs_repository.dart';
|
||||
|
||||
enum _JobSort { newestFirst, oldestFirst, byDueDate, byType }
|
||||
|
||||
const _kSortLabels = [
|
||||
'Yeniden Eskiye',
|
||||
'Eskiden Yeniye',
|
||||
'Vade Tarihine Göre',
|
||||
'Türe Göre',
|
||||
];
|
||||
|
||||
class LabAllJobsScreen extends ConsumerStatefulWidget {
|
||||
const LabAllJobsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LabAllJobsScreen> createState() => _LabAllJobsScreenState();
|
||||
}
|
||||
|
||||
class _LabAllJobsScreenState extends ConsumerState<LabAllJobsScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
final _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
_JobSort _sort = _JobSort.newestFirst;
|
||||
bool _bulkAccepting = false;
|
||||
final Map<String, int?> _counts = {
|
||||
'all': null,
|
||||
'pending': null,
|
||||
'in_progress': null,
|
||||
'sent': null,
|
||||
'delivered': null,
|
||||
};
|
||||
final _pendingTabKey = GlobalKey<_PendingJobsTabState>();
|
||||
|
||||
// null entry = Tümü (bütün statüsler)
|
||||
static const List<String?> _statuses = [null, 'pending', 'in_progress', 'sent', 'delivered'];
|
||||
static const _tabLabels = ['Tümü', 'Onay Bekleyen', 'Devam Eden', 'Gönderildi', 'Teslim Edildi'];
|
||||
String _countKey(String? s) => s ?? 'all';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final isDelivery = ref.read(authProvider).activeTenant?.isDeliveryOnly ?? false;
|
||||
_tabController = TabController(length: 5, vsync: this, initialIndex: isDelivery ? 3 : 0);
|
||||
_tabController.addListener(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
_fetchAllCounts();
|
||||
}
|
||||
|
||||
Future<void> _fetchAllCounts() async {
|
||||
final tenantId = ref.read(authProvider).activeTenant?.tenant.id;
|
||||
if (tenantId == null) return;
|
||||
final results = await Future.wait(
|
||||
_statuses.map((s) => LabJobsRepository.instance.countByStatus(tenantId, s)),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
for (var i = 0; i < _statuses.length; i++) {
|
||||
_counts[_countKey(_statuses[i])] = results[i];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
setState(() => _searchQuery = value);
|
||||
}
|
||||
|
||||
Future<void> _showSortOptions() async {
|
||||
final result = await showSortSheet(
|
||||
context,
|
||||
title: 'Sıralama',
|
||||
options: _kSortLabels,
|
||||
current: _sort.index,
|
||||
);
|
||||
if (result != null) {
|
||||
setState(() => _sort = _JobSort.values[result]);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _bulkAccept() async {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() => _bulkAccepting = true);
|
||||
try {
|
||||
await LabJobsRepository.instance.bulkAcceptPending(tenantId);
|
||||
_pendingTabKey.currentState?._load();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Tüm işler kabul edildi')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _bulkAccepting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isSortActive = _sort != _JobSort.newestFirst;
|
||||
final onPendingTab = _tabController.index == 1;
|
||||
final pendingCount = _counts['pending'];
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: GradientAppBar(
|
||||
title: 'İşler',
|
||||
category: 'LABORATUVAR',
|
||||
searchController: _searchController,
|
||||
onSearchChanged: _onSearchChanged,
|
||||
searchHint: 'Protokol, klinik veya tür ara...',
|
||||
actions: [
|
||||
if (!onPendingTab)
|
||||
IconButton(
|
||||
onPressed: _showSortOptions,
|
||||
tooltip: 'Sırala',
|
||||
icon: Badge(
|
||||
isLabelVisible: isSortActive,
|
||||
smallSize: 8,
|
||||
backgroundColor: AppColors.accent,
|
||||
child: const Icon(Icons.sort_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: onPendingTab && (pendingCount == null || pendingCount > 0)
|
||||
? FloatingActionButton.extended(
|
||||
onPressed: _bulkAccepting ? null : _bulkAccept,
|
||||
icon: _bulkAccepting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.done_all),
|
||||
label: Text(_bulkAccepting ? 'Kabul ediliyor...' : 'Tümünü Kabul Et'),
|
||||
backgroundColor: AppColors.pending,
|
||||
foregroundColor: Colors.white,
|
||||
)
|
||||
: null,
|
||||
body: Column(
|
||||
children: [
|
||||
PillTabs(
|
||||
tabs: _tabLabels,
|
||||
selected: _tabController.index,
|
||||
onSelect: (i) => _tabController.animateTo(i),
|
||||
counts: _statuses.map((s) => _counts[_countKey(s)]).toList(),
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_LabJobsTab(
|
||||
status: null,
|
||||
searchQuery: _searchQuery,
|
||||
sort: _sort,
|
||||
onCountLoaded: (c) => setState(() => _counts['all'] = c),
|
||||
),
|
||||
_PendingJobsTab(
|
||||
key: _pendingTabKey,
|
||||
searchQuery: _searchQuery,
|
||||
onCountLoaded: (c) => setState(() => _counts['pending'] = c),
|
||||
),
|
||||
_LabJobsTab(
|
||||
status: 'in_progress',
|
||||
searchQuery: _searchQuery,
|
||||
sort: _sort,
|
||||
onCountLoaded: (c) => setState(() => _counts['in_progress'] = c),
|
||||
),
|
||||
_LabJobsTab(
|
||||
status: 'sent',
|
||||
searchQuery: _searchQuery,
|
||||
sort: _sort,
|
||||
onCountLoaded: (c) => setState(() => _counts['sent'] = c),
|
||||
),
|
||||
_LabJobsTab(
|
||||
status: 'delivered',
|
||||
searchQuery: _searchQuery,
|
||||
sort: _sort,
|
||||
onCountLoaded: (c) => setState(() => _counts['delivered'] = c),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pending (Onay Bekleyen) tab ───────────────────────────────────────────────
|
||||
|
||||
class _PendingJobsTab extends ConsumerStatefulWidget {
|
||||
const _PendingJobsTab({super.key, required this.searchQuery, this.onCountLoaded});
|
||||
final String searchQuery;
|
||||
final void Function(int)? onCountLoaded;
|
||||
|
||||
@override
|
||||
ConsumerState<_PendingJobsTab> createState() => _PendingJobsTabState();
|
||||
}
|
||||
|
||||
class _PendingJobsTabState extends ConsumerState<_PendingJobsTab> {
|
||||
late Future<List<Job>> _future;
|
||||
late UnsubFn _unsub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
_unsub = RealtimeService.instance.watch(
|
||||
'jobs',
|
||||
filter: 'lab_tenant_id="$tenantId" && status="pending"',
|
||||
onEvent: (_) { if (mounted) _load(); },
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unsub();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_future = LabJobsRepository.instance.listInbound(tenantId, status: 'pending', limit: 50);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _acceptJob(Job job) async {
|
||||
try {
|
||||
await LabJobsRepository.instance.acceptJob(job);
|
||||
_load();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('İş kabul edildi')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<Job> _filtered(List<Job> jobs) {
|
||||
final q = widget.searchQuery.toLowerCase().trim();
|
||||
if (q.isEmpty) return jobs;
|
||||
return jobs.where((j) =>
|
||||
j.patientCode.toLowerCase().contains(q) ||
|
||||
(j.clinicName?.toLowerCase().contains(q) ?? false) ||
|
||||
j.prostheticType.label.toLowerCase().contains(q)
|
||||
).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RefreshIndicator(
|
||||
color: AppColors.accent,
|
||||
onRefresh: () async => _load(),
|
||||
child: FutureBuilder<List<Job>>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator(color: AppColors.accent));
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Icon(Icons.wifi_off_rounded, color: AppColors.cancelled, size: 30),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Hata: ${snap.error}', style: const TextStyle(color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('Tekrar Dene'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final all = snap.data!;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.onCountLoaded?.call(all.length);
|
||||
});
|
||||
|
||||
final jobs = _filtered(all);
|
||||
|
||||
if (jobs.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.successBg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(Icons.inbox_outlined, color: AppColors.success, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.searchQuery.isNotEmpty ? 'Sonuç bulunamadı' : 'Onay bekleyen iş yok',
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
||||
),
|
||||
if (widget.searchQuery.isEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
const Text('Tüm işler kabul edildi', style: TextStyle(color: AppColors.textSecondary, fontSize: 13)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
|
||||
itemCount: jobs.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final job = jobs[i];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _PendingJobCard(
|
||||
job: job,
|
||||
onAccept: () => _acceptJob(job),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingJobCard extends StatefulWidget {
|
||||
const _PendingJobCard({required this.job, required this.onAccept});
|
||||
final Job job;
|
||||
final VoidCallback onAccept;
|
||||
|
||||
@override
|
||||
State<_PendingJobCard> createState() => _PendingJobCardState();
|
||||
}
|
||||
|
||||
class _PendingJobCardState extends State<_PendingJobCard> {
|
||||
bool _accepting = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final job = widget.job;
|
||||
return Dismissible(
|
||||
key: ValueKey(job.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.check_rounded, color: Colors.white, size: 28),
|
||||
SizedBox(height: 4),
|
||||
Text('Kabul Et', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
),
|
||||
confirmDismiss: (_) async {
|
||||
setState(() => _accepting = true);
|
||||
try {
|
||||
await LabJobsRepository.instance.acceptJob(job);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (mounted) setState(() => _accepting = false);
|
||||
}
|
||||
},
|
||||
child: Material(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: () => context.push('/lab/jobs/${job.id}'),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black.withValues(alpha: 0.04), blurRadius: 8, offset: const Offset(0, 2)),
|
||||
],
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 4,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.pending,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(14),
|
||||
bottomLeft: Radius.circular(14),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
job.patientCode,
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.local_hospital_outlined, size: 12, color: AppColors.textMuted),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
job.clinicName ?? 'Klinik',
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.pendingBg,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
job.prostheticType.label,
|
||||
style: const TextStyle(fontSize: 11, color: AppColors.pending, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
if (job.dueDate != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
const Icon(Icons.calendar_today_outlined, size: 11, color: AppColors.textMuted),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'${job.dueDate!.day.toString().padLeft(2, '0')}.${job.dueDate!.month.toString().padLeft(2, '0')}.${job.dueDate!.year}',
|
||||
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_accepting
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.success),
|
||||
)
|
||||
: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.successBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppColors.success.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: const Text(
|
||||
'Kabul Et',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.success),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LabJobsTab extends ConsumerStatefulWidget {
|
||||
const _LabJobsTab({
|
||||
required this.status,
|
||||
required this.searchQuery,
|
||||
required this.sort,
|
||||
this.onCountLoaded,
|
||||
});
|
||||
|
||||
final String? status; // null = tüm statüsler
|
||||
final String searchQuery;
|
||||
final _JobSort sort;
|
||||
final void Function(int)? onCountLoaded;
|
||||
|
||||
@override
|
||||
ConsumerState<_LabJobsTab> createState() => _LabJobsTabState();
|
||||
}
|
||||
|
||||
class _LabJobsTabState extends ConsumerState<_LabJobsTab> {
|
||||
late Future<List<Job>> _future;
|
||||
late UnsubFn _unsub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
_unsub = RealtimeService.instance.watch(
|
||||
'jobs',
|
||||
filter: 'lab_tenant_id="$tenantId"',
|
||||
onEvent: (_) { if (mounted) _load(); },
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unsub();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_future = LabJobsRepository.instance
|
||||
.listInbound(tenantId, status: widget.status, limit: 50);
|
||||
});
|
||||
}
|
||||
|
||||
List<Job> _applyFilters(List<Job> jobs) {
|
||||
var list = jobs;
|
||||
|
||||
final q = widget.searchQuery.toLowerCase().trim();
|
||||
if (q.isNotEmpty) {
|
||||
list = list.where((j) {
|
||||
return j.patientCode.toLowerCase().contains(q) ||
|
||||
(j.clinicName?.toLowerCase().contains(q) ?? false) ||
|
||||
j.prostheticType.label.toLowerCase().contains(q) ||
|
||||
(j.currentStep?.label.toLowerCase().contains(q) ?? false);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
final sorted = List<Job>.from(list);
|
||||
switch (widget.sort) {
|
||||
case _JobSort.newestFirst:
|
||||
sorted.sort((a, b) => b.dateCreated.compareTo(a.dateCreated));
|
||||
case _JobSort.oldestFirst:
|
||||
sorted.sort((a, b) => a.dateCreated.compareTo(b.dateCreated));
|
||||
case _JobSort.byDueDate:
|
||||
sorted.sort((a, b) {
|
||||
if (a.dueDate == null && b.dueDate == null) return 0;
|
||||
if (a.dueDate == null) return 1;
|
||||
if (b.dueDate == null) return -1;
|
||||
return a.dueDate!.compareTo(b.dueDate!);
|
||||
});
|
||||
case _JobSort.byType:
|
||||
sorted.sort(
|
||||
(a, b) => a.prostheticType.label.compareTo(b.prostheticType.label));
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RefreshIndicator(
|
||||
color: AppColors.accent,
|
||||
onRefresh: () async => _load(),
|
||||
child: FutureBuilder<List<Job>>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent));
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
child: const Icon(Icons.wifi_off_rounded,
|
||||
color: AppColors.cancelled, size: 30),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Hata: ${snap.error}',
|
||||
style:
|
||||
const TextStyle(color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('Tekrar Dene'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final all = snap.data!;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.onCountLoaded?.call(all.length);
|
||||
});
|
||||
|
||||
final jobs = _applyFilters(all);
|
||||
|
||||
if (jobs.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(20)),
|
||||
child: const Icon(Icons.work_off_outlined,
|
||||
color: AppColors.inProgress, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.searchQuery.isNotEmpty
|
||||
? 'Sonuç bulunamadı'
|
||||
: 'Henüz iş yok',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
itemCount: jobs.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final job = jobs[i];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _LabJobCard(
|
||||
job: job,
|
||||
onTap: () => context.push('/lab/jobs/${job.id}'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LabJobCard extends StatelessWidget {
|
||||
const _LabJobCard({required this.job, required this.onTap});
|
||||
|
||||
final Job job;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isOverdue =
|
||||
job.dueDate != null && job.dueDate!.isBefore(DateTime.now());
|
||||
final accentColor = _statusColor(job.status);
|
||||
|
||||
return Semantics(
|
||||
label: job.patientCode,
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
child: Material(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: accentColor,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(14),
|
||||
bottomLeft: Radius.circular(14),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
job.patientCode,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (job.currentStep != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
job.currentStep!.label,
|
||||
style: const TextStyle(
|
||||
color: AppColors.inProgress,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.local_hospital_outlined,
|
||||
size: 12, color: AppColors.textMuted),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
job.clinicName ?? 'Klinik',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
job.prostheticType.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (job.dueDate != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.calendar_today_outlined,
|
||||
size: 11,
|
||||
color: isOverdue
|
||||
? AppColors.cancelled
|
||||
: AppColors.textMuted),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
_fmt(job.dueDate!),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: isOverdue
|
||||
? AppColors.cancelled
|
||||
: AppColors.textMuted,
|
||||
fontWeight: isOverdue
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 10),
|
||||
child: Icon(Icons.chevron_right,
|
||||
color: AppColors.textMuted, size: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _fmt(DateTime d) =>
|
||||
'${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
|
||||
|
||||
Color _statusColor(JobStatus status) {
|
||||
switch (status) {
|
||||
case JobStatus.pending:
|
||||
return AppColors.pending;
|
||||
case JobStatus.inProgress:
|
||||
return AppColors.inProgress;
|
||||
case JobStatus.sent:
|
||||
return AppColors.accent;
|
||||
case JobStatus.delivered:
|
||||
return AppColors.success;
|
||||
case JobStatus.cancelled:
|
||||
return AppColors.cancelled;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/services/realtime_service.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/job.dart';
|
||||
import '../../../models/job_file.dart';
|
||||
import '../../../features/shared/job_files_repository.dart';
|
||||
import '../../../features/shared/job_files_panel.dart';
|
||||
import '../../../core/services/job_history_service.dart';
|
||||
import 'lab_jobs_repository.dart';
|
||||
|
||||
// ── Adaptive sheet helper ────────────────────────────────────────────────────
|
||||
|
||||
void _showAdaptive(BuildContext context, Widget content) {
|
||||
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||
if (isDesktop) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => Dialog(
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => content,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LabJobDetailScreen extends ConsumerStatefulWidget {
|
||||
const LabJobDetailScreen({super.key, required this.jobId});
|
||||
final String jobId;
|
||||
|
||||
@override
|
||||
ConsumerState<LabJobDetailScreen> createState() => _LabJobDetailScreenState();
|
||||
}
|
||||
|
||||
class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
|
||||
Job? _job;
|
||||
bool _loadingJob = false;
|
||||
String? _loadError;
|
||||
bool _isActing = false;
|
||||
late Future<List<JobFile>> _filesFuture;
|
||||
late UnsubFn _unsub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
_loadFiles();
|
||||
_unsub = RealtimeService.instance.watch(
|
||||
'jobs',
|
||||
topic: widget.jobId,
|
||||
onEvent: (_) { if (mounted && !_isActing) _load(); },
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unsub();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() { _loadingJob = true; _loadError = null; });
|
||||
try {
|
||||
final job = await LabJobsRepository.instance.getJob(widget.jobId);
|
||||
if (mounted) setState(() { _job = job; _loadingJob = false; });
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _loadError = e.toString(); _loadingJob = false; });
|
||||
}
|
||||
}
|
||||
|
||||
void _loadFiles() {
|
||||
setState(() {
|
||||
_filesFuture = JobFilesRepository.instance.listForJob(widget.jobId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _cancelJob(Job job) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('İşi İptal Et'),
|
||||
content: const Text('Bu iş geri alınamaz şekilde iptal edilir. Onaylıyor musunuz?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Vazgeç')),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('İptal Et'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
setState(() => _isActing = true);
|
||||
try {
|
||||
final updated = await LabJobsRepository.instance.cancelJob(job.id, job);
|
||||
if (mounted) {
|
||||
setState(() { _job = _job!.copyWith(status: updated.status); _isActing = false; });
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('İş iptal edildi.')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isActing = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _acceptJob(Job job) async {
|
||||
setState(() => _isActing = true);
|
||||
try {
|
||||
final updated = await LabJobsRepository.instance.acceptJob(job);
|
||||
if (mounted) {
|
||||
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('İş kabul edildi')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isActing = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showHandToClinicSheet(Job job) {
|
||||
_showAdaptive(
|
||||
context,
|
||||
_HandToClinicSheet(
|
||||
job: job,
|
||||
onDone: (Job updated) {
|
||||
if (mounted) setState(() => _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _statusColor(JobStatus status) {
|
||||
return switch (status) {
|
||||
JobStatus.pending => AppColors.pending,
|
||||
JobStatus.inProgress => AppColors.inProgress,
|
||||
JobStatus.sent => AppColors.accent,
|
||||
JobStatus.delivered => AppColors.success,
|
||||
JobStatus.cancelled => AppColors.cancelled,
|
||||
};
|
||||
}
|
||||
|
||||
Color _statusBg(JobStatus status) {
|
||||
return switch (status) {
|
||||
JobStatus.pending => AppColors.pendingBg,
|
||||
JobStatus.inProgress => AppColors.inProgressBg,
|
||||
JobStatus.sent => AppColors.inProgressBg,
|
||||
JobStatus.delivered => AppColors.successBg,
|
||||
JobStatus.cancelled => AppColors.cancelledBg,
|
||||
};
|
||||
}
|
||||
|
||||
String _formatDate(DateTime dt, {bool withTime = false}) {
|
||||
final d = '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}';
|
||||
if (!withTime || (dt.hour == 0 && dt.minute == 0)) return d;
|
||||
return '$d ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('İş Detayı'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
body: _buildBody(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_loadingJob && _job == null) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent));
|
||||
}
|
||||
if (_loadError != null && _job == null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
child: const Icon(Icons.wifi_off_rounded,
|
||||
color: AppColors.cancelled, size: 30),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Hata: $_loadError',
|
||||
style: const TextStyle(color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('Tekrar Dene'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_job == null) return const SizedBox.shrink();
|
||||
|
||||
{
|
||||
final job = _job!;
|
||||
final membership = ref.read(authProvider).activeTenant;
|
||||
final isDeliveryOnly = membership?.isDeliveryOnly ?? false;
|
||||
final canCancelJobs = membership?.canCancelJobs ?? true;
|
||||
final canSendToClinic = !isDeliveryOnly &&
|
||||
job.status == JobStatus.inProgress &&
|
||||
job.location == JobLocation.atLab;
|
||||
final canAccept = !isDeliveryOnly && job.status == JobStatus.pending;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Header card
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
job.patientCode,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: _statusBg(job.status),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
job.status.label,
|
||||
style: TextStyle(
|
||||
color: _statusColor(job.status),
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_InfoRow(
|
||||
icon: Icons.business,
|
||||
label: 'Klinik',
|
||||
value: job.clinicName ?? '-'),
|
||||
_InfoRow(
|
||||
icon: Icons.medical_services_outlined,
|
||||
label: 'Protez Tipi',
|
||||
value: job.prostheticType.label),
|
||||
_InfoRow(
|
||||
icon: Icons.format_list_numbered,
|
||||
label: 'Üye Sayısı',
|
||||
value: '${job.memberCount} üye'),
|
||||
if (job.color != null)
|
||||
_InfoRow(
|
||||
icon: Icons.color_lens_outlined,
|
||||
label: 'Renk',
|
||||
value: job.color!),
|
||||
if (job.dueDate != null)
|
||||
_InfoRow(
|
||||
icon: Icons.calendar_today,
|
||||
label: 'Teslim Tarihi',
|
||||
value: _formatDate(job.dueDate!, withTime: true),
|
||||
valueColor: job.dueDate!.isBefore(DateTime.now())
|
||||
? AppColors.cancelled
|
||||
: null),
|
||||
_InfoRow(
|
||||
icon: Icons.add_circle_outline,
|
||||
label: 'Oluşturulma',
|
||||
value: _formatDate(job.dateCreated)),
|
||||
if (job.price != null && job.currency != null)
|
||||
_InfoRow(
|
||||
icon: Icons.attach_money,
|
||||
label: 'Fiyat',
|
||||
value:
|
||||
'${job.price!.toStringAsFixed(2)} ${job.currency}'),
|
||||
if (job.description != null &&
|
||||
job.description!.isNotEmpty)
|
||||
_InfoRow(
|
||||
icon: Icons.notes,
|
||||
label: 'Açıklama',
|
||||
value: job.description!),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Stepper
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'İş Adımları',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: job.provaRequired
|
||||
? AppColors.inProgressBg
|
||||
: AppColors.successBg,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
job.provaRequired ? 'Provalı' : 'Provasız',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: job.provaRequired
|
||||
? AppColors.inProgress
|
||||
: AppColors.success,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_JobStepper(
|
||||
steps: job.stepTemplate,
|
||||
currentStep: job.currentStep,
|
||||
historyFuture: JobHistoryService.instance
|
||||
.listForJob(job.id),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Action buttons
|
||||
if (_isActing)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Center(child: CircularProgressIndicator(color: AppColors.accent)),
|
||||
)
|
||||
else ...[
|
||||
if (canAccept)
|
||||
FilledButton.icon(
|
||||
onPressed: () => _acceptJob(job),
|
||||
icon: const Icon(Icons.check_circle_outline),
|
||||
label: const Text('Kabul Et'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
),
|
||||
|
||||
if (canSendToClinic)
|
||||
FilledButton.icon(
|
||||
onPressed: () => _showHandToClinicSheet(job),
|
||||
icon: const Icon(Icons.send_outlined),
|
||||
label: Text(
|
||||
(job.isLastStep)
|
||||
? 'Son Prova - Teslime Gönder'
|
||||
: 'Prova için Kliniğe Gönder',
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
backgroundColor: (job.isLastStep)
|
||||
? AppColors.success
|
||||
: AppColors.inProgress,
|
||||
),
|
||||
),
|
||||
|
||||
if (canCancelJobs && job.status == JobStatus.pending) ...[
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _cancelJob(job),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
label: const Text('İşi İptal Et'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
foregroundColor: AppColors.cancelled,
|
||||
side: const BorderSide(color: AppColors.cancelled),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
JobFilesPanel(
|
||||
job: job,
|
||||
filesFuture: _filesFuture,
|
||||
onRefresh: _loadFiles,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hand to Clinic Sheet ─────────────────────────────────────────────────────
|
||||
|
||||
class _HandToClinicSheet extends StatefulWidget {
|
||||
const _HandToClinicSheet({required this.job, required this.onDone});
|
||||
final Job job;
|
||||
final void Function(Job updatedJob) onDone;
|
||||
|
||||
@override
|
||||
State<_HandToClinicSheet> createState() => _HandToClinicSheetState();
|
||||
}
|
||||
|
||||
class _HandToClinicSheetState extends State<_HandToClinicSheet> {
|
||||
final _noteController = TextEditingController();
|
||||
bool _sending = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_noteController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||
final isLast = widget.job.isLastStep;
|
||||
final stepLabel = widget.job.currentStep?.label ?? '';
|
||||
final buttonLabel = isLast
|
||||
? (widget.job.provaRequired ? 'Son Prova · Teslime Gönder' : 'Teslime Gönder')
|
||||
: '$stepLabel için Kliniğe Gönder';
|
||||
final buttonColor = isLast ? AppColors.success : AppColors.inProgress;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: isDesktop ? Radius.zero : const Radius.circular(20),
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 24,
|
||||
bottom: isDesktop
|
||||
? 24
|
||||
: MediaQuery.of(context).viewInsets.bottom + 24,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
buttonLabel,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
isLast
|
||||
? 'İş teslim edilecek olarak işaretlenecek.'
|
||||
: 'İş klinikteki prova için gönderilecek.',
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _noteController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Not (isteğe bağlı)',
|
||||
hintText: 'Klinik için not ekleyin...',
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _sending
|
||||
? null
|
||||
: () async {
|
||||
setState(() => _sending = true);
|
||||
final navigator = Navigator.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final updated = await LabJobsRepository.instance.handToClinic(
|
||||
widget.job.id,
|
||||
widget.job,
|
||||
note: _noteController.text.trim().isEmpty
|
||||
? null
|
||||
: _noteController.text.trim(),
|
||||
);
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(isLast
|
||||
? 'İş teslim için gönderildi'
|
||||
: 'Prova için klinik\'e gönderildi')),
|
||||
);
|
||||
if (context.mounted) widget.onDone(updated);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
setState(() => _sending = false);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
backgroundColor: buttonColor,
|
||||
),
|
||||
child: _sending
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: Text(buttonLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Info Row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
const _InfoRow({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.valueColor,
|
||||
});
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final Color? valueColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: AppColors.textMuted),
|
||||
const SizedBox(width: 10),
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(color: AppColors.textSecondary, fontSize: 13),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: valueColor ?? AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Job Stepper ───────────────────────────────────────────────────────────────
|
||||
|
||||
class _JobStepper extends StatelessWidget {
|
||||
const _JobStepper({
|
||||
required this.steps,
|
||||
required this.currentStep,
|
||||
required this.historyFuture,
|
||||
});
|
||||
final List<JobStep> steps;
|
||||
final JobStep? currentStep;
|
||||
final Future<List<JobHistoryEntry>> historyFuture;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<JobHistoryEntry>>(
|
||||
future: historyFuture,
|
||||
builder: (ctx, snap) {
|
||||
final history = snap.data ?? [];
|
||||
// Revizyon sayısı per adım
|
||||
final Map<JobStep, int> revisionCounts = {};
|
||||
for (final e in history) {
|
||||
if (e.action == JobHistoryAction.revisionRequested && e.step != null) {
|
||||
revisionCounts[e.step!] = (revisionCounts[e.step!] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
final currentIndex =
|
||||
currentStep != null ? steps.indexOf(currentStep!) : -1;
|
||||
|
||||
return Column(
|
||||
children: List.generate(steps.length, (i) {
|
||||
final step = steps[i];
|
||||
final isCompleted = i < currentIndex;
|
||||
final isCurrent = i == currentIndex;
|
||||
final isLastItem = i == steps.length - 1;
|
||||
final revCount = revisionCounts[step] ?? 0;
|
||||
|
||||
Color dotColor;
|
||||
IconData dotIcon;
|
||||
if (isCompleted) {
|
||||
dotColor = AppColors.success;
|
||||
dotIcon = Icons.check_circle;
|
||||
} else if (isCurrent) {
|
||||
dotColor = AppColors.inProgress;
|
||||
dotIcon = Icons.radio_button_checked;
|
||||
} else {
|
||||
dotColor = AppColors.muted;
|
||||
dotIcon = Icons.radio_button_unchecked;
|
||||
}
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Icon(dotIcon, color: dotColor, size: 24),
|
||||
if (!isLastItem)
|
||||
Container(
|
||||
width: 2,
|
||||
height: 44,
|
||||
color: i < currentIndex
|
||||
? AppColors.success.withValues(alpha: 0.35)
|
||||
: AppColors.border,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 2, bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
step.label,
|
||||
style: TextStyle(
|
||||
fontWeight: isCurrent
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
color: isCompleted
|
||||
? AppColors.success
|
||||
: isCurrent
|
||||
? AppColors.inProgress
|
||||
: AppColors.textMuted,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
if (revCount > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'$revCount revizyon',
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.cancelled,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (isCurrent)
|
||||
Text(
|
||||
step.description,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/services/realtime_service.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/gradient_app_bar.dart';
|
||||
import '../../../models/job.dart';
|
||||
import 'lab_jobs_repository.dart';
|
||||
|
||||
class LabJobsInboundScreen extends ConsumerStatefulWidget {
|
||||
const LabJobsInboundScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LabJobsInboundScreen> createState() =>
|
||||
_LabJobsInboundScreenState();
|
||||
}
|
||||
|
||||
class _LabJobsInboundScreenState extends ConsumerState<LabJobsInboundScreen> {
|
||||
late Future<List<Job>> _future;
|
||||
bool _acceptingAll = false;
|
||||
late UnsubFn _unsub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
_unsub = RealtimeService.instance.watch(
|
||||
'jobs',
|
||||
filter: "lab_tenant_id='$tenantId'",
|
||||
onEvent: (_) { if (mounted) _load(); },
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unsub();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_future =
|
||||
LabJobsRepository.instance.listInbound(tenantId, status: 'pending');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _acceptJob(Job job) async {
|
||||
try {
|
||||
await LabJobsRepository.instance.acceptJob(job);
|
||||
_load();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('İş kabul edildi')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _bulkAccept() async {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() => _acceptingAll = true);
|
||||
try {
|
||||
await LabJobsRepository.instance.bulkAcceptPending(tenantId);
|
||||
_load();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Tüm işler kabul edildi')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _acceptingAll = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: GradientAppBar(
|
||||
title: 'Gelen İşler',
|
||||
category: 'LABORATUVAR',
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _acceptingAll ? null : _bulkAccept,
|
||||
icon: _acceptingAll
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.done_all),
|
||||
label:
|
||||
Text(_acceptingAll ? 'Kabul ediliyor...' : 'Tümünü Kabul Et'),
|
||||
backgroundColor: AppColors.pending,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async => _load(),
|
||||
child: FutureBuilder<List<Job>>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Hata: ${snap.error}'),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: _load,
|
||||
child: const Text('Tekrar Dene'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final jobs = snap.data!;
|
||||
|
||||
if (jobs.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.inbox_outlined, size: 64, color: AppColors.textMuted),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Bekleyen iş yok',
|
||||
style: TextStyle(
|
||||
fontSize: 16, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 80),
|
||||
itemCount: jobs.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final job = jobs[i];
|
||||
return _InboundJobCard(
|
||||
job: job,
|
||||
onAccept: () => _acceptJob(job),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InboundJobCard extends StatefulWidget {
|
||||
const _InboundJobCard({required this.job, required this.onAccept});
|
||||
final Job job;
|
||||
final VoidCallback onAccept;
|
||||
|
||||
@override
|
||||
State<_InboundJobCard> createState() => _InboundJobCardState();
|
||||
}
|
||||
|
||||
class _InboundJobCardState extends State<_InboundJobCard> {
|
||||
bool _accepting = false;
|
||||
|
||||
String _formatDate(DateTime dt) =>
|
||||
'${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final job = widget.job;
|
||||
return Semantics(
|
||||
label: job.patientCode,
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
child: Dismissible(
|
||||
key: ValueKey(job.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.check, color: Colors.white, size: 28),
|
||||
SizedBox(height: 4),
|
||||
Text('Kabul Et',
|
||||
style: TextStyle(color: Colors.white, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
confirmDismiss: (_) async {
|
||||
setState(() => _accepting = true);
|
||||
try {
|
||||
await LabJobsRepository.instance.acceptJob(job);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (mounted) setState(() => _accepting = false);
|
||||
}
|
||||
},
|
||||
child: Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: AppColors.pendingBg,
|
||||
child: const Icon(Icons.assignment_outlined,
|
||||
color: AppColors.pending),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
job.patientCode,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
job.clinicName ?? 'Klinik',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
_Chip(
|
||||
label: job.prostheticType.label,
|
||||
color: AppColors.inProgressBg,
|
||||
textColor: AppColors.inProgress,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_Chip(
|
||||
label: '${job.memberCount} üye',
|
||||
color: AppColors.background,
|
||||
textColor: AppColors.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_formatDate(job.dateCreated),
|
||||
style: TextStyle(
|
||||
color: AppColors.textMuted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_accepting
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: FilledButton(
|
||||
onPressed: widget.onAccept,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.success,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 8),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text('Kabul Et',
|
||||
style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Chip extends StatelessWidget {
|
||||
const _Chip(
|
||||
{required this.label,
|
||||
required this.color,
|
||||
required this.textColor});
|
||||
final String label;
|
||||
final Color color;
|
||||
final Color textColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(label,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'dart:async';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../core/services/job_history_service.dart';
|
||||
import '../../../models/job.dart';
|
||||
|
||||
const _listExpand = 'clinic_tenant_id,lab_tenant_id';
|
||||
const _detailExpand = 'clinic_tenant_id,lab_tenant_id,patient_id';
|
||||
|
||||
class LabJobsRepository {
|
||||
LabJobsRepository._();
|
||||
static final instance = LabJobsRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<List<Job>> listInbound(
|
||||
String labTenantId, {
|
||||
String? status,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) async {
|
||||
final filterParts = ['lab_tenant_id = "$labTenantId"'];
|
||||
if (status != null) filterParts.add('status = "$status"');
|
||||
|
||||
final result = await _pb.collection('jobs').getList(
|
||||
page: page,
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
expand: _listExpand,
|
||||
);
|
||||
return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated)));
|
||||
}
|
||||
|
||||
Future<List<Job>> listInProgress(String labTenantId, {int limit = 50, String? location}) async {
|
||||
final filterParts = ['lab_tenant_id = "$labTenantId"', 'status = "in_progress"'];
|
||||
if (location != null) filterParts.add('location = "$location"');
|
||||
final result = await _pb.collection('jobs').getList(
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
expand: _listExpand,
|
||||
);
|
||||
return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) {
|
||||
if (a.dueDate == null && b.dueDate == null) return b.dateCreated.compareTo(a.dateCreated);
|
||||
if (a.dueDate == null) return 1;
|
||||
if (b.dueDate == null) return -1;
|
||||
final cmp = a.dueDate!.compareTo(b.dueDate!);
|
||||
return cmp != 0 ? cmp : b.dateCreated.compareTo(a.dateCreated);
|
||||
}));
|
||||
}
|
||||
|
||||
Future<Job> getJob(String jobId) async {
|
||||
final record = await _pb.collection('jobs').getOne(jobId, expand: _detailExpand);
|
||||
return Job.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<Job> acceptJob(Job pendingJob) async {
|
||||
final firstStep = pendingJob.stepTemplate.first;
|
||||
final record = await _pb.collection('jobs').update(pendingJob.id, body: {
|
||||
'status': 'in_progress',
|
||||
'current_step': firstStep.value,
|
||||
'location': 'at_lab',
|
||||
});
|
||||
final job = Job.fromJson(record.toJson());
|
||||
unawaited(JobHistoryService.instance.append(
|
||||
jobId: pendingJob.id,
|
||||
clinicTenantId: job.clinicTenantId,
|
||||
labTenantId: job.labTenantId,
|
||||
action: JobHistoryAction.accepted,
|
||||
step: firstStep,
|
||||
));
|
||||
return job;
|
||||
}
|
||||
|
||||
Future<Job> handToClinic(String jobId, Job job, {String? note}) async {
|
||||
final isFinal = job.currentStep == JobStep.cilaBitim;
|
||||
final patch = isFinal
|
||||
? {'status': 'sent', 'location': 'at_clinic'}
|
||||
: {'location': 'at_clinic'};
|
||||
|
||||
final record = await _pb.collection('jobs').update(jobId, body: patch);
|
||||
final updated = Job.fromJson(record.toJson());
|
||||
unawaited(JobHistoryService.instance.append(
|
||||
jobId: jobId,
|
||||
clinicTenantId: job.clinicTenantId,
|
||||
labTenantId: job.labTenantId,
|
||||
action: JobHistoryAction.handedToClinic,
|
||||
step: job.currentStep,
|
||||
note: note,
|
||||
));
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<Job> cancelJob(String jobId, Job job) async {
|
||||
final record = await _pb.collection('jobs').update(jobId, body: {
|
||||
'status': 'cancelled',
|
||||
});
|
||||
unawaited(JobHistoryService.instance.append(
|
||||
jobId: jobId,
|
||||
clinicTenantId: job.clinicTenantId,
|
||||
labTenantId: job.labTenantId,
|
||||
action: JobHistoryAction.cancelled,
|
||||
step: job.currentStep,
|
||||
));
|
||||
return Job.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<void> bulkAcceptPending(String labTenantId) async {
|
||||
final pending = await listInbound(labTenantId, status: 'pending', limit: 200);
|
||||
await Future.wait(pending.map((j) => acceptJob(j)));
|
||||
}
|
||||
|
||||
Future<int> countByStatus(String labTenantId, String? status) async {
|
||||
final filter = status != null
|
||||
? 'lab_tenant_id = "$labTenantId" && status = "$status"'
|
||||
: 'lab_tenant_id = "$labTenantId"';
|
||||
final r = await _pb.collection('jobs').getList(perPage: 1, filter: filter);
|
||||
return r.totalItems;
|
||||
}
|
||||
|
||||
Future<int> countDelivered(String labTenantId, {DateTime? from, DateTime? to}) async {
|
||||
final parts = ['lab_tenant_id = "$labTenantId"', 'status = "delivered"'];
|
||||
if (from != null) parts.add('updated >= "${_date(from)}"');
|
||||
if (to != null) parts.add('updated < "${_date(to)}"');
|
||||
final r = await _pb.collection('jobs').getList(perPage: 1, filter: parts.join(' && '));
|
||||
return r.totalItems;
|
||||
}
|
||||
|
||||
static String _date(DateTime d) => d.toIso8601String().split('T').first;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../models/prosthetic_product.dart';
|
||||
|
||||
class LabProductsRepository {
|
||||
LabProductsRepository._();
|
||||
static final instance = LabProductsRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<List<ProstheticProduct>> listProducts(
|
||||
String labTenantId, {
|
||||
bool? isActive,
|
||||
}) async {
|
||||
final filterParts = ['lab_tenant_id = "$labTenantId"'];
|
||||
if (isActive != null) filterParts.add('is_active = $isActive');
|
||||
|
||||
final result = await _pb.collection('prosthetic_products').getList(
|
||||
filter: filterParts.join(' && '),
|
||||
perPage: 200,
|
||||
);
|
||||
return (result.items.map((r) => ProstheticProduct.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => a.name.compareTo(b.name)));
|
||||
}
|
||||
|
||||
Future<ProstheticProduct> createProduct(ProstheticProduct product) async {
|
||||
final record = await _pb.collection('prosthetic_products').create(body: product.toJson());
|
||||
return ProstheticProduct.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<ProstheticProduct> updateProduct(String id, Map<String, dynamic> patch) async {
|
||||
final record = await _pb.collection('prosthetic_products').update(id, body: patch);
|
||||
return ProstheticProduct.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<void> deleteProduct(String id) async {
|
||||
await _pb.collection('prosthetic_products').delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/gradient_app_bar.dart';
|
||||
import '../../../models/prosthetic_product.dart';
|
||||
import 'lab_products_repository.dart';
|
||||
|
||||
const _prostheticTypes = [
|
||||
('metal_porselen', 'Metal Porselen'),
|
||||
('zirkonyum', 'Zirkonyum'),
|
||||
('implant_ustu_zirkonyum', 'İmplant Üstü Zirkonyum'),
|
||||
('gecici', 'Geçici'),
|
||||
('e_max', 'E-Max'),
|
||||
('diger', 'Diğer'),
|
||||
];
|
||||
|
||||
String _typeLabel(String value) {
|
||||
for (final t in _prostheticTypes) {
|
||||
if (t.$1 == value) return t.$2;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── Adaptive sheet helper ────────────────────────────────────────────────────
|
||||
|
||||
void _showAdaptive(BuildContext context, Widget content) {
|
||||
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||
if (isDesktop) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => Dialog(
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => content,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LabProductsScreen extends ConsumerStatefulWidget {
|
||||
const LabProductsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LabProductsScreen> createState() => _LabProductsScreenState();
|
||||
}
|
||||
|
||||
class _LabProductsScreenState extends ConsumerState<LabProductsScreen> {
|
||||
late Future<List<ProstheticProduct>> _future;
|
||||
final _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_future = LabProductsRepository.instance.listProducts(tenantId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _toggleActive(ProstheticProduct product) async {
|
||||
try {
|
||||
await LabProductsRepository.instance
|
||||
.updateProduct(product.id, {'is_active': !product.isActive});
|
||||
_load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteProduct(ProstheticProduct product) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Ürünü Sil'),
|
||||
content: Text(
|
||||
'"${product.name}" ürününü silmek istediğinize emin misiniz?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('İptal'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.cancelled),
|
||||
child: const Text('Sil'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
await LabProductsRepository.instance.deleteProduct(product.id);
|
||||
_load();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ürün silindi')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showProductSheet({ProstheticProduct? existing}) {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
_showAdaptive(
|
||||
context,
|
||||
_ProductForm(
|
||||
labTenantId: tenantId,
|
||||
existing: existing,
|
||||
onSaved: () {
|
||||
Navigator.of(context).pop();
|
||||
_load();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: GradientAppBar(
|
||||
title: 'Ürün Kataloğu',
|
||||
category: 'LABORATUVAR',
|
||||
searchController: _searchController,
|
||||
onSearchChanged: (v) => setState(() => _searchQuery = v),
|
||||
searchHint: 'Ürün adı veya türü ara...',
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _showProductSheet(),
|
||||
backgroundColor: AppColors.accent,
|
||||
foregroundColor: Colors.white,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Yeni Ürün'),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
color: AppColors.accent,
|
||||
onRefresh: () async => _load(),
|
||||
child: FutureBuilder<List<ProstheticProduct>>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent));
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
child: const Icon(Icons.wifi_off_rounded,
|
||||
color: AppColors.cancelled, size: 30),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Hata: ${snap.error}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('Tekrar Dene')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final allProducts = snap.data!;
|
||||
final q = _searchQuery.toLowerCase().trim();
|
||||
final products = q.isEmpty
|
||||
? allProducts
|
||||
: allProducts.where((p) =>
|
||||
p.name.toLowerCase().contains(q) ||
|
||||
_typeLabel(p.prostheticType).toLowerCase().contains(q)).toList();
|
||||
|
||||
if (allProducts.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(20)),
|
||||
child: const Icon(Icons.inventory_2_outlined,
|
||||
size: 32, color: AppColors.inProgress),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
q.isNotEmpty ? 'Sonuç bulunamadı' : 'Henüz ürün eklenmedi',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (q.isEmpty) FilledButton.icon(
|
||||
onPressed: () => _showProductSheet(),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('İlk Ürünü Ekle'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (products.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(20)),
|
||||
child: const Icon(Icons.search_off_rounded,
|
||||
size: 32, color: AppColors.inProgress),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Sonuç bulunamadı',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 80),
|
||||
itemCount: products.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final product = products[i];
|
||||
final statusColor =
|
||||
product.isActive ? AppColors.inProgress : AppColors.textMuted;
|
||||
final statusBg =
|
||||
product.isActive ? AppColors.inProgressBg : AppColors.surfaceVariant;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: GestureDetector(
|
||||
onLongPress: () => _deleteProduct(product),
|
||||
child: Material(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: () => _showProductSheet(existing: product),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color:
|
||||
Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2))
|
||||
]),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: statusBg,
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(Icons.medical_services_outlined,
|
||||
color: statusColor, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
product.name,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: product.isActive
|
||||
? AppColors.textPrimary
|
||||
: AppColors.textMuted),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_typeLabel(product.prostheticType),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary),
|
||||
),
|
||||
if (product.unitPrice != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${product.unitPrice!.toStringAsFixed(2)} ${product.currency ?? 'TRY'}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.success),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Switch(
|
||||
value: product.isActive,
|
||||
onChanged: (_) => _toggleActive(product),
|
||||
activeTrackColor: AppColors.accent,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined,
|
||||
color: AppColors.textSecondary,
|
||||
size: 20),
|
||||
onPressed: () =>
|
||||
_showProductSheet(existing: product),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Product Form ─────────────────────────────────────────────────────────────
|
||||
|
||||
class _ProductForm extends StatefulWidget {
|
||||
const _ProductForm({
|
||||
required this.labTenantId,
|
||||
required this.onSaved,
|
||||
this.existing,
|
||||
});
|
||||
|
||||
final String labTenantId;
|
||||
final ProstheticProduct? existing;
|
||||
final VoidCallback onSaved;
|
||||
|
||||
@override
|
||||
State<_ProductForm> createState() => _ProductFormState();
|
||||
}
|
||||
|
||||
class _ProductFormState extends State<_ProductForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _nameCtrl;
|
||||
late final TextEditingController _priceCtrl;
|
||||
late final TextEditingController _descCtrl;
|
||||
late String _selectedType;
|
||||
late String _currency;
|
||||
late bool _isActive;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final p = widget.existing;
|
||||
_nameCtrl = TextEditingController(text: p?.name ?? '');
|
||||
_priceCtrl = TextEditingController(
|
||||
text: p?.unitPrice != null ? p!.unitPrice!.toString() : '');
|
||||
_descCtrl = TextEditingController(text: p?.description ?? '');
|
||||
_selectedType = p?.prostheticType ?? _prostheticTypes.first.$1;
|
||||
_currency = p?.currency ?? 'TRY';
|
||||
_isActive = p?.isActive ?? true;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_priceCtrl.dispose();
|
||||
_descCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _saving = true);
|
||||
|
||||
final price = double.tryParse(_priceCtrl.text.trim());
|
||||
final product = ProstheticProduct(
|
||||
id: widget.existing?.id ?? '',
|
||||
labTenantId: widget.labTenantId,
|
||||
name: _nameCtrl.text.trim(),
|
||||
prostheticType: _selectedType,
|
||||
unitPrice: price,
|
||||
currency: _currency,
|
||||
isActive: _isActive,
|
||||
description:
|
||||
_descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim(),
|
||||
);
|
||||
|
||||
try {
|
||||
if (widget.existing != null) {
|
||||
await LabProductsRepository.instance.updateProduct(
|
||||
widget.existing!.id,
|
||||
product.toJson(),
|
||||
);
|
||||
} else {
|
||||
await LabProductsRepository.instance.createProduct(product);
|
||||
}
|
||||
widget.onSaved();
|
||||
} catch (e) {
|
||||
setState(() => _saving = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||
final isEdit = widget.existing != null;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: isDesktop ? Radius.zero : const Radius.circular(20),
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 24,
|
||||
bottom: isDesktop
|
||||
? 24
|
||||
: MediaQuery.of(context).viewInsets.bottom + 24,
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
isEdit ? 'Ürünü Düzenle' : 'Yeni Ürün',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close,
|
||||
color: AppColors.textSecondary),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Name
|
||||
TextFormField(
|
||||
controller: _nameCtrl,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Ürün Adı *'),
|
||||
validator: (v) =>
|
||||
v == null || v.trim().isEmpty ? 'Ürün adı gerekli' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Prosthetic type dropdown
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _selectedType,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Protez Tipi *'),
|
||||
items: _prostheticTypes
|
||||
.map((t) => DropdownMenuItem(
|
||||
value: t.$1,
|
||||
child: Text(t.$2),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => _selectedType = v!),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Price + currency row
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextFormField(
|
||||
controller: _priceCtrl,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Birim Fiyat'),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true),
|
||||
validator: (v) {
|
||||
if (v != null && v.isNotEmpty) {
|
||||
if (double.tryParse(v) == null) {
|
||||
return 'Geçerli fiyat girin';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: _currency,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Para Birimi'),
|
||||
items: ['TRY', 'USD', 'EUR']
|
||||
.map((c) => DropdownMenuItem(
|
||||
value: c,
|
||||
child: Text(c),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => _currency = v!),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Description
|
||||
TextFormField(
|
||||
controller: _descCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Açıklama (isteğe bağlı)'),
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Active toggle
|
||||
SwitchListTile(
|
||||
title: const Text('Aktif',
|
||||
style: TextStyle(color: AppColors.textPrimary)),
|
||||
value: _isActive,
|
||||
onChanged: (v) => setState(() => _isActive = v),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeTrackColor: AppColors.accent,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
FilledButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48)),
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: Text(isEdit ? 'Kaydet' : 'Ekle'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/l10n/app_strings.dart';
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/providers/locale_provider.dart';
|
||||
import '../../../core/router/app_router.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/tenant.dart';
|
||||
import '../../shared/tenant_team_screen.dart';
|
||||
import '../connections/lab_connections_screen.dart';
|
||||
|
||||
class LabSettingsScreen extends ConsumerWidget {
|
||||
const LabSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final auth = ref.watch(authProvider);
|
||||
final s = ref.watch(stringsProvider);
|
||||
final profile = auth.profile;
|
||||
final membership = auth.activeTenant;
|
||||
final tenant = membership?.tenant;
|
||||
final canEdit = membership?.isAdmin ?? false;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(s.settings)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// User card
|
||||
_SectionHeader(title: s.userInfo),
|
||||
_UserCard(profile: profile),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Lab info
|
||||
_SectionHeader(
|
||||
title: s.labInfo,
|
||||
action: canEdit
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.edit_outlined,
|
||||
size: 18, color: AppColors.accent),
|
||||
tooltip: s.edit,
|
||||
onPressed: () => _showEditSheet(context, ref, tenant, s),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
_InfoCard(children: [
|
||||
_InfoTile(
|
||||
icon: Icons.science_outlined,
|
||||
label: s.labName,
|
||||
value: tenant?.companyName ?? '-',
|
||||
),
|
||||
_InfoTile(
|
||||
icon: Icons.payments_outlined,
|
||||
label: s.currency,
|
||||
value: tenant?.defaultCurrency ?? 'TRY',
|
||||
),
|
||||
_InfoTileBadge(
|
||||
icon: Icons.circle_outlined,
|
||||
label: s.status,
|
||||
value: tenant?.status == 'active' ? s.active : (tenant?.status ?? '-'),
|
||||
badgeColor: AppColors.success,
|
||||
badgeBg: AppColors.successBg,
|
||||
),
|
||||
_InfoTile(
|
||||
icon: Icons.star_outline,
|
||||
label: s.role,
|
||||
value: _roleLabel(membership?.role, s),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Connections
|
||||
if (membership?.showConnections ?? false) ...[
|
||||
_SectionHeader(title: s.connections),
|
||||
_InfoCard(children: [
|
||||
_NavTile(
|
||||
icon: Icons.link_rounded,
|
||||
iconColor: AppColors.inProgress,
|
||||
iconBg: AppColors.inProgressBg,
|
||||
title: s.clinicConnections,
|
||||
subtitle: s.clinicConnectionsSub,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const LabConnectionsScreen()),
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Other memberships
|
||||
if (auth.memberships.length > 1) ...[
|
||||
_SectionHeader(title: s.otherMemberships),
|
||||
_InfoCard(children: [
|
||||
for (final m
|
||||
in auth.memberships.where((m) => m.id != membership?.id))
|
||||
_NavTile(
|
||||
icon: Icons.switch_account_outlined,
|
||||
iconColor: AppColors.inProgress,
|
||||
iconBg: AppColors.inProgressBg,
|
||||
title: m.tenant.companyName,
|
||||
subtitle: _tenantKindLabel(m.tenant.kind, s),
|
||||
onTap: () {
|
||||
ref.read(authProvider.notifier).setActiveTenant(m);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(s.tenantSelected(m.tenant.companyName))),
|
||||
);
|
||||
},
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Team management + Reports
|
||||
if (membership?.canManageUsers ?? false) ...[
|
||||
_SectionHeader(title: s.management),
|
||||
_InfoCard(children: [
|
||||
_NavTile(
|
||||
icon: Icons.group_outlined,
|
||||
iconColor: AppColors.inProgress,
|
||||
iconBg: AppColors.inProgressBg,
|
||||
title: s.team,
|
||||
subtitle: s.teamSub,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const TenantTeamScreen()),
|
||||
),
|
||||
),
|
||||
_NavTile(
|
||||
icon: Icons.discount_outlined,
|
||||
iconColor: AppColors.success,
|
||||
iconBg: AppColors.successBg,
|
||||
title: s.discounts,
|
||||
subtitle: s.discountsSub,
|
||||
onTap: () => context.push(routeLabDiscounts),
|
||||
),
|
||||
_NavTile(
|
||||
icon: Icons.bar_chart_rounded,
|
||||
iconColor: AppColors.accent,
|
||||
iconBg: AppColors.inProgressBg,
|
||||
title: s.reports,
|
||||
subtitle: s.reportsSub,
|
||||
onTap: () => context.push(routeLabReports),
|
||||
),
|
||||
_NavTile(
|
||||
icon: Icons.auto_awesome_outlined,
|
||||
iconColor: const Color(0xFF7C3AED),
|
||||
iconBg: const Color(0xFFF3E8FF),
|
||||
title: s.aiAssistant,
|
||||
subtitle: s.aiAssistantSub,
|
||||
onTap: () => context.push(routeLabAi),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Preferences (language)
|
||||
_SectionHeader(title: s.preferences),
|
||||
_InfoCard(children: [
|
||||
_NavTile(
|
||||
icon: Icons.language_outlined,
|
||||
iconColor: AppColors.accent,
|
||||
iconBg: AppColors.inProgressBg,
|
||||
title: s.appLanguage,
|
||||
subtitle: _currentLanguageLabel(ref.watch(localeProvider).languageCode, s),
|
||||
onTap: () => _showLanguagePicker(context, ref, s),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Sign out
|
||||
_SignOutCard(ref: ref, s: s),
|
||||
const SizedBox(height: 32),
|
||||
const Center(
|
||||
child: Text('DLS — Dental Lab System',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditSheet(BuildContext context, WidgetRef ref, Tenant? tenant, AppStrings s) {
|
||||
if (tenant == null) return;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _EditTenantSheet(
|
||||
tenant: tenant,
|
||||
s: s,
|
||||
onSave: (name, currency) async {
|
||||
await ref.read(authProvider.notifier).updateTenantInfo(
|
||||
tenantId: tenant.id,
|
||||
companyName: name,
|
||||
defaultCurrency: currency,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showLanguagePicker(BuildContext context, WidgetRef ref, AppStrings s) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _LanguagePickerSheet(s: s, ref: ref),
|
||||
);
|
||||
}
|
||||
|
||||
static String _tenantKindLabel(TenantKind? kind, AppStrings s) =>
|
||||
switch (kind) {
|
||||
TenantKind.clinic => s.tenantKindClinic,
|
||||
TenantKind.lab => s.tenantKindLab,
|
||||
null => '-',
|
||||
};
|
||||
|
||||
static String _currentLanguageLabel(String code, AppStrings s) => switch (code) {
|
||||
'en' => s.languageEnglish,
|
||||
'ru' => s.languageRussian,
|
||||
'ar' => s.languageArabic,
|
||||
'de' => s.languageGerman,
|
||||
_ => s.languageTurkish,
|
||||
};
|
||||
|
||||
static String _roleLabel(TenantRole? role, AppStrings s) => switch (role) {
|
||||
TenantRole.owner => s.roleOwner,
|
||||
TenantRole.admin => s.roleAdmin,
|
||||
TenantRole.technician => s.roleTechnician,
|
||||
TenantRole.delivery => s.roleDelivery,
|
||||
TenantRole.finance => s.roleFinance,
|
||||
TenantRole.doctor => s.roleDoctor,
|
||||
TenantRole.member => s.roleMember,
|
||||
null => '-',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Language picker sheet ─────────────────────────────────────────────────────
|
||||
|
||||
class _LanguagePickerSheet extends ConsumerWidget {
|
||||
const _LanguagePickerSheet({required this.s, required this.ref});
|
||||
final AppStrings s;
|
||||
final WidgetRef ref;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef _) {
|
||||
final currentLocale = ref.watch(localeProvider);
|
||||
|
||||
final options = [
|
||||
('tr', '🇹🇷', s.languageTurkish),
|
||||
('en', '🇬🇧', s.languageEnglish),
|
||||
('ru', '🇷🇺', s.languageRussian),
|
||||
('ar', '🇸🇦', s.languageArabic),
|
||||
('de', '🇩🇪', s.languageGerman),
|
||||
];
|
||||
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.border,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
s.languageSelection,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
for (final (code, flag, label) in options)
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
leading: Text(flag, style: const TextStyle(fontSize: 24)),
|
||||
title: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
trailing: currentLocale.languageCode == code
|
||||
? const Icon(Icons.check_circle_rounded,
|
||||
color: AppColors.accent)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(localeProvider.notifier).setLocale(Locale(code));
|
||||
ref.read(authProvider.notifier).updateLanguage(code);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
SizedBox(height: MediaQuery.paddingOf(context).bottom + 4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edit sheet ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _EditTenantSheet extends StatefulWidget {
|
||||
const _EditTenantSheet({
|
||||
required this.tenant,
|
||||
required this.s,
|
||||
required this.onSave,
|
||||
});
|
||||
final Tenant tenant;
|
||||
final AppStrings s;
|
||||
final Future<void> Function(String companyName, String currency) onSave;
|
||||
|
||||
@override
|
||||
State<_EditTenantSheet> createState() => _EditTenantSheetState();
|
||||
}
|
||||
|
||||
class _EditTenantSheetState extends State<_EditTenantSheet> {
|
||||
late final TextEditingController _nameController;
|
||||
late String _selectedCurrency;
|
||||
bool _saving = false;
|
||||
|
||||
static const _currencies = [
|
||||
('TRY', '₺', 'Türk Lirası'),
|
||||
('USD', '\$', 'US Dollar'),
|
||||
('EUR', '€', 'Euro'),
|
||||
('GBP', '£', 'British Pound'),
|
||||
('AED', 'د.إ', 'UAE Dirham'),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameController = TextEditingController(text: widget.tenant.companyName);
|
||||
_selectedCurrency = widget.tenant.defaultCurrency;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final name = _nameController.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
setState(() => _saving = true);
|
||||
final navigator = Navigator.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await widget.onSave(name, _selectedCurrency);
|
||||
navigator.pop();
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('${widget.s.errorPrefix}: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = widget.s;
|
||||
return Padding(
|
||||
padding:
|
||||
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.border,
|
||||
borderRadius: BorderRadius.circular(2)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(s.editLabInfo,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: s.labName,
|
||||
hintText: s.labNameHint,
|
||||
),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(s.currency,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedCurrency,
|
||||
decoration: InputDecoration(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.border)),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.border)),
|
||||
),
|
||||
items: [
|
||||
for (final (code, symbol, name) in _currencies)
|
||||
DropdownMenuItem(
|
||||
value: code,
|
||||
child: Text('$symbol $name ($code)',
|
||||
style: const TextStyle(fontSize: 14)),
|
||||
),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) setState(() => _selectedCurrency = v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_saving)
|
||||
const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent))
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 48)),
|
||||
child: Text(s.save),
|
||||
),
|
||||
SizedBox(height: MediaQuery.paddingOf(context).bottom + 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reusable UI pieces ────────────────────────────────────────────────────────
|
||||
|
||||
class _UserCard extends StatelessWidget {
|
||||
const _UserCard({required this.profile});
|
||||
final dynamic profile;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final displayName = (profile?.displayName?.isNotEmpty == true)
|
||||
? profile!.displayName as String
|
||||
: 'Kullanıcı';
|
||||
final initial = (profile?.displayName?.isNotEmpty == true
|
||||
? (profile!.displayName as String)[0]
|
||||
: (profile?.email as String?)?[0] ?? '?')
|
||||
.toUpperCase();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(14)),
|
||||
child: Center(
|
||||
child: Text(initial,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.inProgress)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 2),
|
||||
Text(profile?.email as String? ?? '',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.title, this.action});
|
||||
final String title;
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.accent,
|
||||
letterSpacing: 0.3),
|
||||
),
|
||||
),
|
||||
if (action != null) action!,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoCard extends StatelessWidget {
|
||||
const _InfoCard({required this.children});
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < children.length; i++) ...[
|
||||
children[i],
|
||||
if (i < children.length - 1)
|
||||
const Divider(
|
||||
height: 1, indent: 16, endIndent: 16, color: AppColors.border),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoTile extends StatelessWidget {
|
||||
const _InfoTile(
|
||||
{required this.icon, required this.label, required this.value});
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: AppColors.textSecondary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: AppColors.textMuted)),
|
||||
const SizedBox(height: 2),
|
||||
Text(value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoTileBadge extends StatelessWidget {
|
||||
const _InfoTileBadge({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.badgeColor,
|
||||
required this.badgeBg,
|
||||
});
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final Color badgeColor;
|
||||
final Color badgeBg;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: AppColors.textSecondary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: AppColors.textMuted)),
|
||||
),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(value,
|
||||
style: TextStyle(
|
||||
color: badgeColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavTile extends StatelessWidget {
|
||||
const _NavTile({
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.iconBg,
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final Color iconBg;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||
leading: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: iconBg, borderRadius: BorderRadius.circular(9)),
|
||||
child: Icon(icon, color: iconColor, size: 18),
|
||||
),
|
||||
title: Text(title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
subtitle: subtitle != null
|
||||
? Text(subtitle!,
|
||||
style: const TextStyle(color: AppColors.textSecondary))
|
||||
: null,
|
||||
trailing:
|
||||
const Icon(Icons.chevron_right, color: AppColors.textSecondary),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SignOutCard extends StatelessWidget {
|
||||
const _SignOutCard({required this.ref, required this.s});
|
||||
final WidgetRef ref;
|
||||
final AppStrings s;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.cancelledBg),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||
leading: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(9)),
|
||||
child: const Icon(Icons.logout,
|
||||
color: AppColors.cancelled, size: 18),
|
||||
),
|
||||
title: Text(s.signOut,
|
||||
style: const TextStyle(
|
||||
color: AppColors.cancelled, fontWeight: FontWeight.w600)),
|
||||
onTap: () async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(s.signOutTitle),
|
||||
content: Text(s.signOutConfirm),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(s.cancel)),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.cancelled),
|
||||
child: Text(s.signOut),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await ref.read(authProvider.notifier).signOut();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,930 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/providers/auth_provider.dart';
|
||||
import '../../core/services/ai_actions.dart';
|
||||
import '../../core/services/ai_context_builder.dart';
|
||||
import '../../core/services/ai_service.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/utils/file_download_helper.dart';
|
||||
import '../../models/job_file.dart';
|
||||
import '../../models/tenant.dart';
|
||||
|
||||
class AiChatScreen extends ConsumerStatefulWidget {
|
||||
const AiChatScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AiChatScreen> createState() => _AiChatScreenState();
|
||||
}
|
||||
|
||||
class _AiChatScreenState extends ConsumerState<AiChatScreen> {
|
||||
final _inputController = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
final List<_Message> _messages = [];
|
||||
|
||||
String? _systemPrompt;
|
||||
bool _loadingContext = true;
|
||||
bool _streaming = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadContext();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_inputController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadContext() async {
|
||||
try {
|
||||
final membership = ref.read(authProvider).activeTenant!;
|
||||
final prompt = await AiContextBuilder.instance.build(membership);
|
||||
if (mounted) setState(() { _systemPrompt = prompt; _loadingContext = false; });
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loadingContext = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _send([String? override]) async {
|
||||
final text = (override ?? _inputController.text).trim();
|
||||
if (text.isEmpty || _streaming || _systemPrompt == null) return;
|
||||
_inputController.clear();
|
||||
FocusScope.of(context).unfocus();
|
||||
|
||||
final apiMessages = [
|
||||
..._messages.map((m) => {'role': m.isUser ? 'user' : 'assistant', 'content': m.rawText}),
|
||||
{'role': 'user', 'content': text},
|
||||
];
|
||||
|
||||
setState(() {
|
||||
_messages.add(_Message.user(text));
|
||||
_messages.add(_Message.assistantStreaming());
|
||||
_streaming = true;
|
||||
});
|
||||
_scrollToBottom();
|
||||
|
||||
var accumulated = '';
|
||||
try {
|
||||
final stream = AiService.instance.streamChat(
|
||||
systemPrompt: _systemPrompt!,
|
||||
messages: apiMessages,
|
||||
);
|
||||
await for (final chunk in stream) {
|
||||
if (!mounted) break;
|
||||
accumulated += chunk;
|
||||
setState(() => _messages[_messages.length - 1] = _Message.assistantStreaming(accumulated));
|
||||
_scrollToBottom();
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() => _messages[_messages.length - 1] = _Message.assistantDone(accumulated));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _messages[_messages.length - 1] = _Message.error('Bir hata olustu, tekrar deneyin.'));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _streaming = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
title: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(Icons.auto_awesome, size: 16, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Text('AI Asistan'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (_messages.isNotEmpty)
|
||||
IconButton(
|
||||
onPressed: () => setState(() => _messages.clear()),
|
||||
icon: const Icon(Icons.refresh_outlined, size: 20),
|
||||
tooltip: 'Sohbeti temizle',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _loadingContext
|
||||
? const _LoadingContext()
|
||||
: _messages.isEmpty
|
||||
? _WelcomeView(onSuggestion: _send)
|
||||
: ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
itemCount: _messages.length,
|
||||
itemBuilder: (_, i) => _MessageBubble(
|
||||
message: _messages[i],
|
||||
membership: ref.read(authProvider).activeTenant!,
|
||||
),
|
||||
),
|
||||
),
|
||||
_InputBar(
|
||||
controller: _inputController,
|
||||
enabled: !_loadingContext && !_streaming,
|
||||
streaming: _streaming,
|
||||
onSend: _send,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Message model ─────────────────────────────────────────────────────────────
|
||||
|
||||
enum _MsgKind { user, streaming, done, error }
|
||||
|
||||
class _Message {
|
||||
_Message._({required this.kind, required this.rawText});
|
||||
|
||||
factory _Message.user(String text) =>
|
||||
_Message._(kind: _MsgKind.user, rawText: text);
|
||||
|
||||
factory _Message.assistantStreaming([String text = '']) =>
|
||||
_Message._(kind: _MsgKind.streaming, rawText: text);
|
||||
|
||||
factory _Message.assistantDone(String text) =>
|
||||
_Message._(kind: _MsgKind.done, rawText: text);
|
||||
|
||||
factory _Message.error(String text) =>
|
||||
_Message._(kind: _MsgKind.error, rawText: text);
|
||||
|
||||
final _MsgKind kind;
|
||||
final String rawText;
|
||||
|
||||
bool get isUser => kind == _MsgKind.user;
|
||||
bool get isStreaming => kind == _MsgKind.streaming;
|
||||
bool get isError => kind == _MsgKind.error;
|
||||
}
|
||||
|
||||
// ── Welcome view ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _WelcomeView extends StatelessWidget {
|
||||
const _WelcomeView({required this.onSuggestion});
|
||||
final void Function(String) onSuggestion;
|
||||
|
||||
static const _suggestions = [
|
||||
'Bekleyen islerimin ozeti nedir?',
|
||||
'Gecikmiş iş var mı?',
|
||||
'Bu ay kac is tamamlandi?',
|
||||
'Revizyon oranım ne durumda?',
|
||||
'Finans durumumu ozetle.',
|
||||
'Son yuklenen dosyalari goster.',
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 32, 20, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accent,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Icon(Icons.auto_awesome, size: 28, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Merhaba! Size nasil yardimci olabilirim?',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'Isler, finans ve ekip hakkinda soru sorabilir, islem yapabilirsiniz.',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
const Text(
|
||||
'ONERILER',
|
||||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.textMuted, letterSpacing: 0.8),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _suggestions
|
||||
.map((s) => _SuggestionChip(label: s, onTap: () => onSuggestion(s)))
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SuggestionChip extends StatelessWidget {
|
||||
const _SuggestionChip({required this.label, required this.onTap});
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textPrimary)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Message bubble ────────────────────────────────────────────────────────────
|
||||
|
||||
class _MessageBubble extends StatelessWidget {
|
||||
const _MessageBubble({required this.message, required this.membership});
|
||||
final _Message message;
|
||||
final TenantMembership membership;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (message.isUser) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accent,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
bottomLeft: Radius.circular(16),
|
||||
bottomRight: Radius.circular(4),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
message.rawText,
|
||||
style: const TextStyle(fontSize: 14, height: 1.5, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
margin: const EdgeInsets.only(right: 8, top: 2),
|
||||
decoration: BoxDecoration(color: AppColors.accent, borderRadius: BorderRadius.circular(8)),
|
||||
child: const Icon(Icons.auto_awesome, size: 14, color: Colors.white),
|
||||
),
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(4),
|
||||
topRight: Radius.circular(16),
|
||||
bottomLeft: Radius.circular(16),
|
||||
bottomRight: Radius.circular(16),
|
||||
),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: message.isStreaming && message.rawText.isEmpty
|
||||
? const _TypingDots()
|
||||
: _MessageContent(message: message, membership: membership),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Message content: markdown + action buttons ────────────────────────────────
|
||||
|
||||
class _MessageContent extends StatelessWidget {
|
||||
const _MessageContent({required this.message, required this.membership});
|
||||
final _Message message;
|
||||
final TenantMembership membership;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (message.isError) {
|
||||
return Text(
|
||||
message.rawText,
|
||||
style: const TextStyle(fontSize: 14, height: 1.5, color: AppColors.cancelled),
|
||||
);
|
||||
}
|
||||
|
||||
// During streaming: show raw text without parsing actions
|
||||
if (message.isStreaming) {
|
||||
return _MarkdownText(message.rawText, color: AppColors.textPrimary);
|
||||
}
|
||||
|
||||
// Done: parse segments → text + action buttons
|
||||
final segments = parseSegments(message.rawText);
|
||||
if (segments.isEmpty) {
|
||||
return _MarkdownText(message.rawText, color: AppColors.textPrimary);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: segments.map((seg) {
|
||||
if (seg is TextSegment) {
|
||||
return _MarkdownText(seg.text, color: AppColors.textPrimary);
|
||||
}
|
||||
if (seg is ActionSegment) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: _ActionCard(action: seg.action, membership: membership),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Action card ───────────────────────────────────────────────────────────────
|
||||
|
||||
enum _ActionState { idle, confirming, loading, success, error, files }
|
||||
|
||||
class _ActionCard extends StatefulWidget {
|
||||
const _ActionCard({required this.action, required this.membership});
|
||||
final AiAction action;
|
||||
final TenantMembership membership;
|
||||
|
||||
@override
|
||||
State<_ActionCard> createState() => _ActionCardState();
|
||||
}
|
||||
|
||||
class _ActionCardState extends State<_ActionCard> {
|
||||
_ActionState _state = _ActionState.idle;
|
||||
String _resultMsg = '';
|
||||
List<JobFile> _files = [];
|
||||
|
||||
AiAction get action => widget.action;
|
||||
|
||||
Future<void> _execute() async {
|
||||
setState(() => _state = _ActionState.loading);
|
||||
final outcome = await AiActionExecutor.execute(action, widget.membership);
|
||||
if (!mounted) return;
|
||||
switch (outcome) {
|
||||
case ActionSuccess(:final message):
|
||||
setState(() { _state = _ActionState.success; _resultMsg = message; });
|
||||
case ActionError(:final error):
|
||||
setState(() { _state = _ActionState.error; _resultMsg = error; });
|
||||
case ActionFiles(:final files):
|
||||
setState(() { _state = _ActionState.files; _files = files; });
|
||||
}
|
||||
}
|
||||
|
||||
void _onTap() {
|
||||
if (action.isDangerous) {
|
||||
setState(() => _state = _ActionState.confirming);
|
||||
} else {
|
||||
_execute();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return switch (_state) {
|
||||
_ActionState.idle => _idleButton(),
|
||||
_ActionState.confirming => _confirmCard(),
|
||||
_ActionState.loading => _loadingCard(),
|
||||
_ActionState.success => _resultCard(success: true),
|
||||
_ActionState.error => _resultCard(success: false),
|
||||
_ActionState.files => _filesCard(),
|
||||
};
|
||||
}
|
||||
|
||||
Widget _idleButton() {
|
||||
final isDanger = action.isDangerous;
|
||||
final isFile = action.isFileAction;
|
||||
final color = isDanger
|
||||
? AppColors.cancelled
|
||||
: isFile
|
||||
? AppColors.inProgress
|
||||
: AppColors.accent;
|
||||
final bgColor = isDanger
|
||||
? AppColors.cancelledBg
|
||||
: isFile
|
||||
? AppColors.inProgressBg
|
||||
: AppColors.accent.withValues(alpha: 0.1);
|
||||
final icon = isDanger
|
||||
? Icons.cancel_outlined
|
||||
: isFile
|
||||
? Icons.folder_outlined
|
||||
: action.type == 'add_member'
|
||||
? Icons.person_add_outlined
|
||||
: Icons.check_circle_outline;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: _onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
action.label,
|
||||
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _confirmCard() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppColors.cancelled.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.warning_amber_rounded, size: 16, color: AppColors.cancelled),
|
||||
const SizedBox(width: 6),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Bu islem geri alinamayabilir.',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.cancelled),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(action.label, style: const TextStyle(fontSize: 13, color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => setState(() => _state = _ActionState.idle),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
side: BorderSide(color: AppColors.border),
|
||||
),
|
||||
child: const Text('Vazgec', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _execute,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.cancelled,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
child: const Text('Onayla', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _loadingCard() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.background,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.accent),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('Isleniyor...', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _resultCard({required bool success}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: success ? AppColors.successBg : AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
success ? Icons.check_circle_outline : Icons.error_outline,
|
||||
size: 16,
|
||||
color: success ? AppColors.success : AppColors.cancelled,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
_resultMsg,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: success ? AppColors.success : AppColors.cancelled,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _filesCard() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppColors.inProgress.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.folder_open_outlined, size: 15, color: AppColors.inProgress),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${_files.length} dosya',
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.inProgress),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
..._files.map((f) => _FileDownloadRow(file: f)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── File download row ─────────────────────────────────────────────────────────
|
||||
|
||||
class _FileDownloadRow extends StatefulWidget {
|
||||
const _FileDownloadRow({required this.file});
|
||||
final JobFile file;
|
||||
|
||||
@override
|
||||
State<_FileDownloadRow> createState() => _FileDownloadRowState();
|
||||
}
|
||||
|
||||
class _FileDownloadRowState extends State<_FileDownloadRow> {
|
||||
bool _downloading = false;
|
||||
|
||||
IconData get _icon => switch (widget.file.kind) {
|
||||
JobFileKind.scan => Icons.view_in_ar_rounded,
|
||||
JobFileKind.image => Icons.image_outlined,
|
||||
JobFileKind.document => Icons.description_outlined,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(_icon, size: 14, color: AppColors.inProgress),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.file.name,
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.textPrimary),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
widget.file.sizeLabel,
|
||||
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_downloading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 1.5, color: AppColors.inProgress),
|
||||
)
|
||||
: GestureDetector(
|
||||
onTap: () async {
|
||||
setState(() => _downloading = true);
|
||||
await FileDownloadHelper.download(context, widget.file);
|
||||
if (mounted) setState(() => _downloading = false);
|
||||
},
|
||||
child: const Icon(Icons.download_outlined, size: 18, color: AppColors.inProgress),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Simple markdown renderer ──────────────────────────────────────────────────
|
||||
|
||||
class _MarkdownText extends StatelessWidget {
|
||||
const _MarkdownText(this.text, {required this.color});
|
||||
final String text;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (text.isEmpty) return const SizedBox.shrink();
|
||||
final lines = text.split('\n');
|
||||
final widgets = <Widget>[];
|
||||
bool prevEmpty = false;
|
||||
|
||||
for (final raw in lines) {
|
||||
final line = raw.trimRight();
|
||||
if (line.isEmpty) {
|
||||
if (!prevEmpty) widgets.add(const SizedBox(height: 6));
|
||||
prevEmpty = true;
|
||||
continue;
|
||||
}
|
||||
prevEmpty = false;
|
||||
|
||||
// Header ## or ###
|
||||
if (line.startsWith('## ') || line.startsWith('### ')) {
|
||||
final content = line.replaceFirst(RegExp(r'^#{2,3}\s+'), '');
|
||||
widgets.add(Padding(
|
||||
padding: const EdgeInsets.only(top: 4, bottom: 2),
|
||||
child: Text(
|
||||
content,
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: color),
|
||||
),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bullet
|
||||
if (line.startsWith('- ') || line.startsWith('• ')) {
|
||||
final content = line.substring(2);
|
||||
widgets.add(Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('• ', style: TextStyle(color: color, height: 1.5, fontSize: 14)),
|
||||
Expanded(child: _inlineText(content, color)),
|
||||
],
|
||||
),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
widgets.add(_inlineText(line, color));
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: widgets,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _inlineText(String text, Color baseColor) {
|
||||
final spans = <InlineSpan>[];
|
||||
final boldPattern = RegExp(r'\*\*(.+?)\*\*');
|
||||
int last = 0;
|
||||
|
||||
for (final m in boldPattern.allMatches(text)) {
|
||||
if (m.start > last) {
|
||||
spans.add(TextSpan(text: text.substring(last, m.start)));
|
||||
}
|
||||
spans.add(TextSpan(
|
||||
text: m.group(1),
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
));
|
||||
last = m.end;
|
||||
}
|
||||
if (last < text.length) spans.add(TextSpan(text: text.substring(last)));
|
||||
|
||||
return RichText(
|
||||
text: TextSpan(
|
||||
style: TextStyle(fontSize: 14, height: 1.5, color: baseColor),
|
||||
children: spans,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Typing dots ───────────────────────────────────────────────────────────────
|
||||
|
||||
class _TypingDots extends StatefulWidget {
|
||||
const _TypingDots();
|
||||
@override
|
||||
State<_TypingDots> createState() => _TypingDotsState();
|
||||
}
|
||||
|
||||
class _TypingDotsState extends State<_TypingDots> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 900))..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() { _ctrl.dispose(); super.dispose(); }
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 18,
|
||||
child: AnimatedBuilder(
|
||||
animation: _ctrl,
|
||||
builder: (_, __) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(3, (i) {
|
||||
final t = ((_ctrl.value * 3) - i).clamp(0.0, 1.0);
|
||||
final opacity = (t < 0.5 ? t * 2 : (1 - t) * 2).clamp(0.3, 1.0);
|
||||
return Container(
|
||||
width: 6, height: 6,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accent.withValues(alpha: opacity),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Input bar ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class _InputBar extends StatelessWidget {
|
||||
const _InputBar({
|
||||
required this.controller,
|
||||
required this.enabled,
|
||||
required this.streaming,
|
||||
required this.onSend,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final bool enabled;
|
||||
final bool streaming;
|
||||
final VoidCallback onSend;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottom = MediaQuery.paddingOf(context).bottom;
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(12, 8, 12, 8 + bottom),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border(top: BorderSide(color: AppColors.border)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
maxLines: 4,
|
||||
minLines: 1,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => onSend(),
|
||||
decoration: InputDecoration(
|
||||
hintText: streaming ? 'Yanit bekleniyor...' : 'Bir sey sorun...',
|
||||
hintStyle: const TextStyle(color: AppColors.textMuted),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderSide: BorderSide(color: AppColors.border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderSide: BorderSide(color: AppColors.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderSide: const BorderSide(color: AppColors.accent, width: 1.5),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: enabled ? onSend : null,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: enabled ? AppColors.accent : AppColors.border,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: streaming
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.arrow_upward_rounded, color: Colors.white, size: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Loading context ───────────────────────────────────────────────────────────
|
||||
|
||||
class _LoadingContext extends StatelessWidget {
|
||||
const _LoadingContext();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(color: AppColors.accent, strokeWidth: 2),
|
||||
SizedBox(height: 12),
|
||||
Text('Veriler yukleniyor...', style: TextStyle(color: AppColors.textSecondary, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'dart:convert';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import '../../core/api/pocketbase_client.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/job.dart';
|
||||
import '../../models/job_file.dart';
|
||||
import 'job_files_repository.dart';
|
||||
|
||||
const _maxFileSizeBytes = 50 * 1024 * 1024; // 50 MB per file
|
||||
const _maxFilesPerJob = 10;
|
||||
|
||||
class JobFilesPanel extends StatefulWidget {
|
||||
const JobFilesPanel({
|
||||
super.key,
|
||||
required this.job,
|
||||
required this.filesFuture,
|
||||
required this.onRefresh,
|
||||
});
|
||||
|
||||
final Job job;
|
||||
final Future<List<JobFile>> filesFuture;
|
||||
final VoidCallback onRefresh;
|
||||
|
||||
@override
|
||||
State<JobFilesPanel> createState() => _JobFilesPanelState();
|
||||
}
|
||||
|
||||
class _JobFilesPanelState extends State<JobFilesPanel> {
|
||||
_UploadState? _upload;
|
||||
List<JobFile>? _files;
|
||||
bool _loadingFiles = false;
|
||||
String? _filesError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_subscribeToFuture(widget.filesFuture);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(JobFilesPanel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.filesFuture != widget.filesFuture) {
|
||||
_subscribeToFuture(widget.filesFuture);
|
||||
}
|
||||
}
|
||||
|
||||
void _subscribeToFuture(Future<List<JobFile>> future) {
|
||||
setState(() { _loadingFiles = true; _filesError = null; });
|
||||
future.then((files) {
|
||||
if (mounted) setState(() { _files = files; _loadingFiles = false; });
|
||||
}).catchError((e) {
|
||||
if (mounted) setState(() { _filesError = _friendlyError(e); _loadingFiles = false; });
|
||||
});
|
||||
}
|
||||
|
||||
static String _friendlyError(Object e) {
|
||||
final s = e.toString();
|
||||
// Strip full ClientException URL dumps — show only the message part
|
||||
final msgMatch = RegExp(r'message: ([^,}]+)').firstMatch(s);
|
||||
if (msgMatch != null) return msgMatch.group(1)!.trim();
|
||||
if (s.length > 100) return 'Sunucu hatası';
|
||||
return s;
|
||||
}
|
||||
|
||||
Future<void> _pickAndUpload() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: true,
|
||||
withData: true,
|
||||
type: FileType.custom,
|
||||
allowedExtensions: [
|
||||
'pdf', 'jpg', 'jpeg', 'png', 'webp',
|
||||
'stl', 'obj', 'ply', 'zip', 'opus', 'mp3', 'mp4'
|
||||
],
|
||||
);
|
||||
if (result == null || result.files.isEmpty || !mounted) return;
|
||||
|
||||
// Client-side size validation
|
||||
for (final pf in result.files) {
|
||||
if (pf.size > _maxFileSizeBytes) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('${pf.name} 50 MB sınırını aşıyor (${_formatSize(pf.size)}).'),
|
||||
backgroundColor: AppColors.cancelled,
|
||||
));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final existingCount = _files?.length ?? 0;
|
||||
final remaining = _maxFilesPerJob - existingCount;
|
||||
if (result.files.length > remaining) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Bu iş en fazla $_maxFilesPerJob dosya alabilir. Şu an $existingCount dosya var; en fazla $remaining dosya daha ekleyebilirsiniz.'),
|
||||
backgroundColor: AppColors.cancelled,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
int uploadedCount = 0;
|
||||
for (var i = 0; i < result.files.length; i++) {
|
||||
final pf = result.files[i];
|
||||
if (pf.bytes == null) continue;
|
||||
|
||||
setState(() {
|
||||
_upload = _UploadState(
|
||||
fileName: pf.name,
|
||||
fileIndex: i + 1,
|
||||
totalFiles: result.files.length,
|
||||
progress: 0,
|
||||
speedBytesPerSec: 0,
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await _uploadWithProgress(
|
||||
pf: pf,
|
||||
onProgress: (progress, speed) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_upload = _upload?.copyWith(progress: progress, speedBytesPerSec: speed);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
uploadedCount++;
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${pf.name} yüklenemedi: ${_friendlyError(e)}'), backgroundColor: AppColors.cancelled),
|
||||
);
|
||||
}
|
||||
setState(() => _upload = null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _upload = null);
|
||||
widget.onRefresh();
|
||||
if (mounted && uploadedCount > 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('$uploadedCount dosya yüklendi.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _uploadWithProgress({
|
||||
required PlatformFile pf,
|
||||
required void Function(double progress, double speedBytesPerSec) onProgress,
|
||||
}) async {
|
||||
final bytes = pf.bytes!;
|
||||
final pb = PocketBaseClient.instance.pb;
|
||||
final baseUrl = 'https://pocket.kovaksoft.com';
|
||||
final uri = Uri.parse('$baseUrl/api/collections/job_files/records');
|
||||
|
||||
final ext = (pf.extension ?? '').toLowerCase();
|
||||
final kind = (ext == 'stl' || ext == 'obj' || ext == 'ply')
|
||||
? JobFileKind.scan
|
||||
: (ext == 'pdf') ? JobFileKind.document : JobFileKind.image;
|
||||
final mimeType = _mimeFromExt(ext);
|
||||
final currentUserId = (pb.authStore.record?.id) ?? '';
|
||||
final token = pb.authStore.token;
|
||||
|
||||
final startTime = DateTime.now().millisecondsSinceEpoch;
|
||||
int sentBytes = 0;
|
||||
|
||||
Stream<List<int>> progressStream(List<int> src) async* {
|
||||
const chunkSize = 65536;
|
||||
var offset = 0;
|
||||
while (offset < src.length) {
|
||||
final end = (offset + chunkSize).clamp(0, src.length);
|
||||
final chunk = src.sublist(offset, end);
|
||||
yield chunk;
|
||||
offset = end;
|
||||
sentBytes = offset;
|
||||
final elapsedMs = DateTime.now().millisecondsSinceEpoch - startTime;
|
||||
final speed = elapsedMs > 0 ? sentBytes / elapsedMs * 1000 : 0.0;
|
||||
onProgress(sentBytes / src.length, speed);
|
||||
// yield control so Flutter can rebuild
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
}
|
||||
|
||||
final request = http.MultipartRequest('POST', uri)
|
||||
..headers['Authorization'] = 'Bearer $token'
|
||||
..fields['job_id'] = widget.job.id
|
||||
..fields['clinic_tenant_id'] = widget.job.clinicTenantId
|
||||
..fields['lab_tenant_id'] = widget.job.labTenantId
|
||||
..fields['uploaded_by'] = currentUserId
|
||||
..fields['kind'] = kind.value
|
||||
..fields['name'] = pf.name
|
||||
..fields['size'] = bytes.length.toString()
|
||||
..fields['mime_type'] = mimeType
|
||||
..files.add(http.MultipartFile(
|
||||
'file',
|
||||
http.ByteStream(progressStream(bytes)),
|
||||
bytes.length,
|
||||
filename: pf.name,
|
||||
));
|
||||
|
||||
final streamed = await request.send();
|
||||
final body = await streamed.stream.bytesToString();
|
||||
|
||||
if (streamed.statusCode < 200 || streamed.statusCode >= 300) {
|
||||
String msg = 'HTTP ${streamed.statusCode}';
|
||||
try {
|
||||
final j = jsonDecode(body) as Map<String, dynamic>;
|
||||
msg = j['message'] as String? ?? msg;
|
||||
} catch (_) {}
|
||||
throw Exception(msg);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _bulkDownload(List<JobFile> files) async {
|
||||
if (files.isEmpty) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final pb = PocketBaseClient.instance.pb;
|
||||
final fileToken = await pb.files.getToken();
|
||||
final dir = await getTemporaryDirectory();
|
||||
await dir.create(recursive: true);
|
||||
|
||||
// Download all files in parallel
|
||||
final results = await Future.wait(
|
||||
files.where((f) => f.downloadUrl.isNotEmpty).map((file) async {
|
||||
final uri = Uri.parse('${file.downloadUrl}?token=$fileToken');
|
||||
final response = await http.get(uri);
|
||||
if (response.statusCode != 200) return null;
|
||||
final path = '${dir.path}/${file.name}';
|
||||
await File(path).writeAsBytes(response.bodyBytes);
|
||||
return XFile(path, mimeType: file.mimeType ?? 'application/octet-stream');
|
||||
}),
|
||||
);
|
||||
|
||||
final xFiles = results.whereType<XFile>().toList();
|
||||
if (xFiles.isEmpty) return;
|
||||
await Share.shareXFiles(
|
||||
xFiles,
|
||||
subject: '${widget.job.patientCode} dosyaları',
|
||||
sharePositionOrigin: const Rect.fromLTWH(100, 100, 200, 1),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('İndirilemedi: $e'), backgroundColor: AppColors.cancelled),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteFile(JobFile file) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Dosyayı Sil'),
|
||||
content: Text(file.name),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('İptal')),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Sil'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
// Optimistic: remove immediately from local list
|
||||
setState(() => _files = _files?.where((f) => f.id != file.id).toList());
|
||||
|
||||
try {
|
||||
await JobFilesRepository.instance.deleteFile(file.id);
|
||||
} catch (e) {
|
||||
final is404 = e.toString().contains('404') || e.toString().contains('wasn\'t found');
|
||||
if (!is404) {
|
||||
// Revert only on transient errors (network, 500) — not when already deleted
|
||||
setState(() => _files = [...?_files, file]);
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(is404 ? 'Dosya zaten silinmiş.' : 'Silinemedi: ${_friendlyError(e)}'),
|
||||
backgroundColor: AppColors.cancelled,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _downloadFile(JobFile file, Rect shareOrigin) async {
|
||||
if (file.downloadUrl.isEmpty) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final pb = PocketBaseClient.instance.pb;
|
||||
final fileToken = await pb.files.getToken();
|
||||
final uri = Uri.parse('${file.downloadUrl}?token=$fileToken');
|
||||
final response = await http.get(uri);
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('HTTP ${response.statusCode}');
|
||||
}
|
||||
final dir = await getTemporaryDirectory();
|
||||
await dir.create(recursive: true);
|
||||
final path = '${dir.path}/${file.name}';
|
||||
await File(path).writeAsBytes(response.bodyBytes);
|
||||
await Share.shareXFiles(
|
||||
[XFile(path, mimeType: file.mimeType ?? 'application/octet-stream')],
|
||||
subject: file.name,
|
||||
sharePositionOrigin: shareOrigin,
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('İndirilemedi: $e'),
|
||||
backgroundColor: AppColors.cancelled,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final uploading = _upload != null;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.attach_file_rounded, size: 18, color: AppColors.accent),
|
||||
const SizedBox(width: 6),
|
||||
const Expanded(
|
||||
child: Text('Dosyalar', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
||||
),
|
||||
if (!uploading) ...[
|
||||
if ((_files?.length ?? 0) >= 2)
|
||||
TextButton.icon(
|
||||
onPressed: () => _bulkDownload(_files!),
|
||||
icon: const Icon(Icons.download_for_offline_outlined, size: 16),
|
||||
label: const Text('Hepsini İndir'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.accent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _pickAndUpload,
|
||||
icon: const Icon(Icons.upload_rounded, size: 16),
|
||||
label: const Text('Yükle'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.accent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
if (_upload != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
_UploadProgressBar(state: _upload!),
|
||||
],
|
||||
|
||||
const SizedBox(height: 8),
|
||||
Builder(builder: (ctx) {
|
||||
if (_loadingFiles && _files == null) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
);
|
||||
}
|
||||
if (_filesError != null && _files == null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
'Dosyalar yüklenemedi: $_filesError',
|
||||
style: const TextStyle(color: AppColors.cancelled, fontSize: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
final files = _files ?? [];
|
||||
if (files.isEmpty && !uploading) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
'Henüz dosya eklenmemiş.',
|
||||
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: files
|
||||
.map((f) => _FileRow(
|
||||
file: f,
|
||||
onDelete: () => _deleteFile(f),
|
||||
onDownload: (origin) => _downloadFile(f, origin),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _formatSize(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
|
||||
static String _mimeFromExt(String ext) => switch (ext) {
|
||||
'jpg' || 'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'webp' => 'image/webp',
|
||||
'pdf' => 'application/pdf',
|
||||
'stl' => 'model/stl',
|
||||
'zip' => 'application/zip',
|
||||
'mp3' => 'audio/mpeg',
|
||||
'mp4' => 'video/mp4',
|
||||
'opus' => 'audio/opus',
|
||||
_ => 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Upload Progress Bar ───────────────────────────────────────────────────────
|
||||
|
||||
class _UploadProgressBar extends StatelessWidget {
|
||||
const _UploadProgressBar({required this.state});
|
||||
final _UploadState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pct = (state.progress * 100).toStringAsFixed(0);
|
||||
final speed = state.speedBytesPerSec;
|
||||
final speedLabel = speed >= 1024 * 1024
|
||||
? '${(speed / 1024 / 1024).toStringAsFixed(1)} MB/s'
|
||||
: '${(speed / 1024).toStringAsFixed(0)} KB/s';
|
||||
|
||||
final fileLabel = state.totalFiles > 1
|
||||
? '${state.fileIndex}/${state.totalFiles} — ${state.fileName}'
|
||||
: state.fileName;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
fileLabel,
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$pct% · $speedLabel',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.accent,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: state.progress,
|
||||
minHeight: 6,
|
||||
backgroundColor: AppColors.background,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.accent),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── File Row ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class _FileRow extends StatefulWidget {
|
||||
const _FileRow({
|
||||
required this.file,
|
||||
required this.onDelete,
|
||||
required this.onDownload,
|
||||
});
|
||||
final JobFile file;
|
||||
final VoidCallback onDelete;
|
||||
final Future<void> Function(Rect) onDownload;
|
||||
|
||||
@override
|
||||
State<_FileRow> createState() => _FileRowState();
|
||||
}
|
||||
|
||||
class _FileRowState extends State<_FileRow> {
|
||||
bool _downloading = false;
|
||||
|
||||
IconData get _icon => switch (widget.file.kind) {
|
||||
JobFileKind.scan => Icons.view_in_ar_rounded,
|
||||
JobFileKind.image => Icons.image_outlined,
|
||||
JobFileKind.document => Icons.description_outlined,
|
||||
};
|
||||
|
||||
final _downloadKey = GlobalKey();
|
||||
|
||||
Future<void> _handleDownload() async {
|
||||
setState(() => _downloading = true);
|
||||
final box = _downloadKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final origin = box != null
|
||||
? box.localToGlobal(Offset.zero) & box.size
|
||||
: const Rect.fromLTWH(100, 100, 200, 1);
|
||||
try {
|
||||
await widget.onDownload(origin);
|
||||
} finally {
|
||||
if (mounted) setState(() => _downloading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(_icon, size: 18, color: AppColors.textMuted),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.file.name,
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
'${widget.file.kind.label} · ${widget.file.sizeLabel}',
|
||||
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_downloading)
|
||||
const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.accent),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
key: _downloadKey,
|
||||
onPressed: _handleDownload,
|
||||
icon: const Icon(Icons.download_outlined, size: 18, color: AppColors.accent),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
tooltip: 'İndir',
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
onPressed: widget.onDelete,
|
||||
icon: const Icon(Icons.delete_outline_rounded, size: 18, color: AppColors.cancelled),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
tooltip: 'Sil',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upload State ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _UploadState {
|
||||
const _UploadState({
|
||||
required this.fileName,
|
||||
required this.fileIndex,
|
||||
required this.totalFiles,
|
||||
required this.progress,
|
||||
required this.speedBytesPerSec,
|
||||
});
|
||||
|
||||
final String fileName;
|
||||
final int fileIndex;
|
||||
final int totalFiles;
|
||||
final double progress;
|
||||
final double speedBytesPerSec;
|
||||
|
||||
_UploadState copyWith({double? progress, double? speedBytesPerSec}) =>
|
||||
_UploadState(
|
||||
fileName: fileName,
|
||||
fileIndex: fileIndex,
|
||||
totalFiles: totalFiles,
|
||||
progress: progress ?? this.progress,
|
||||
speedBytesPerSec: speedBytesPerSec ?? this.speedBytesPerSec,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../core/api/pocketbase_client.dart';
|
||||
import '../../models/job_file.dart';
|
||||
|
||||
class JobFilesRepository {
|
||||
JobFilesRepository._();
|
||||
static final instance = JobFilesRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
static const _baseUrl = 'https://pocket.kovaksoft.com';
|
||||
|
||||
String get _currentUserId => (_pb.authStore.record?.id) ?? '';
|
||||
|
||||
Future<List<JobFile>> listForJob(String jobId) async {
|
||||
final result = await _pb.collection('job_files').getList(
|
||||
filter: 'job_id = "$jobId"',
|
||||
perPage: 200,
|
||||
);
|
||||
final files = result.items.map((r) => JobFile.fromJson(r.toJson(), _baseUrl)).toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return files;
|
||||
}
|
||||
|
||||
Future<JobFile> uploadFile({
|
||||
required String jobId,
|
||||
required String clinicTenantId,
|
||||
required String labTenantId,
|
||||
required JobFileKind kind,
|
||||
required String name,
|
||||
required int size,
|
||||
required List<int> bytes,
|
||||
String? mimeType,
|
||||
}) async {
|
||||
final multipartFile = http.MultipartFile.fromBytes(
|
||||
'file',
|
||||
bytes,
|
||||
filename: name,
|
||||
);
|
||||
final record = await _pb.collection('job_files').create(
|
||||
body: {
|
||||
'job_id': jobId,
|
||||
'clinic_tenant_id': clinicTenantId,
|
||||
'lab_tenant_id': labTenantId,
|
||||
'uploaded_by': _currentUserId,
|
||||
'kind': kind.value,
|
||||
'name': name,
|
||||
'size': size,
|
||||
if (mimeType != null) 'mime_type': mimeType,
|
||||
},
|
||||
files: [multipartFile],
|
||||
);
|
||||
return JobFile.fromJson(record.toJson(), _baseUrl);
|
||||
}
|
||||
|
||||
Future<void> deleteFile(String fileId) async {
|
||||
await _pb.collection('job_files').delete(fileId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../core/api/pocketbase_client.dart';
|
||||
import '../../models/tenant.dart';
|
||||
|
||||
// ── Value objects ─────────────────────────────────────────────────────────────
|
||||
|
||||
const _monthLabels = ['Oca','Şub','Mar','Nis','May','Haz','Tem','Ağu','Eyl','Eki','Kas','Ara'];
|
||||
|
||||
class MonthlyCount {
|
||||
const MonthlyCount({required this.year, required this.month, required this.count});
|
||||
final int year, month, count;
|
||||
String get label => _monthLabels[month - 1];
|
||||
}
|
||||
|
||||
class MonthlyAmount {
|
||||
const MonthlyAmount({required this.year, required this.month, required this.amount});
|
||||
final int year, month;
|
||||
final double amount;
|
||||
String get label => _monthLabels[month - 1];
|
||||
}
|
||||
|
||||
class CounterpartStat {
|
||||
const CounterpartStat({required this.name, required this.jobCount, required this.pendingRevenue, required this.paidRevenue});
|
||||
final String name;
|
||||
final int jobCount;
|
||||
final double pendingRevenue, paidRevenue;
|
||||
double get totalRevenue => pendingRevenue + paidRevenue;
|
||||
}
|
||||
|
||||
class ActivityItem {
|
||||
const ActivityItem({required this.jobId, this.patientCode, required this.action, required this.createdAt, this.note});
|
||||
final String jobId, action;
|
||||
final String? patientCode, note;
|
||||
final DateTime createdAt;
|
||||
|
||||
String get actionLabel => switch (action) {
|
||||
'accepted' => 'İş kabul edildi',
|
||||
'handed_to_clinic' => 'Provaya gönderildi',
|
||||
'approved' => 'Onaylandı',
|
||||
'revision_requested' => 'Revizyon istendi',
|
||||
'delivered' => 'Teslim edildi',
|
||||
'cancelled' => 'İptal edildi',
|
||||
_ => action,
|
||||
};
|
||||
|
||||
bool get isNegative => action == 'revision_requested' || action == 'cancelled';
|
||||
bool get isPositive => action == 'delivered' || action == 'approved' || action == 'accepted';
|
||||
}
|
||||
|
||||
// ── Aggregated metrics ────────────────────────────────────────────────────────
|
||||
|
||||
class ReportMetrics {
|
||||
const ReportMetrics({
|
||||
required this.activeJobs,
|
||||
required this.completedThisMonth,
|
||||
required this.overdueJobs,
|
||||
required this.revisionRate,
|
||||
required this.avgCompletionDays,
|
||||
required this.totalRevenue,
|
||||
required this.pendingRevenue,
|
||||
required this.currency,
|
||||
required this.jobsByStatus,
|
||||
required this.monthlyCounts,
|
||||
required this.monthlyRevenue,
|
||||
required this.byProstheticType,
|
||||
required this.counterpartStats,
|
||||
required this.recentActivity,
|
||||
});
|
||||
|
||||
final int activeJobs;
|
||||
final int completedThisMonth;
|
||||
final int overdueJobs;
|
||||
final double revisionRate; // 0-100
|
||||
final double avgCompletionDays;
|
||||
final double totalRevenue;
|
||||
final double pendingRevenue;
|
||||
final String currency;
|
||||
|
||||
final Map<String, int> jobsByStatus;
|
||||
final List<MonthlyCount> monthlyCounts;
|
||||
final List<MonthlyAmount> monthlyRevenue;
|
||||
final Map<String, int> byProstheticType;
|
||||
final List<CounterpartStat> counterpartStats;
|
||||
final List<ActivityItem> recentActivity;
|
||||
}
|
||||
|
||||
// ── Repository ────────────────────────────────────────────────────────────────
|
||||
|
||||
class ReportsRepository {
|
||||
ReportsRepository._();
|
||||
static final instance = ReportsRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<ReportMetrics> load(String tenantId, TenantKind kind) async {
|
||||
final jobFilter = kind == TenantKind.lab
|
||||
? 'lab_tenant_id = "$tenantId"'
|
||||
: 'clinic_tenant_id = "$tenantId"';
|
||||
final historyFilter = kind == TenantKind.lab
|
||||
? 'lab_tenant_id = "$tenantId"'
|
||||
: 'clinic_tenant_id = "$tenantId"';
|
||||
|
||||
final results = await Future.wait([
|
||||
_pb.collection('jobs').getList(
|
||||
filter: jobFilter,
|
||||
perPage: 500,
|
||||
expand: kind == TenantKind.lab ? 'clinic_tenant_id' : 'lab_tenant_id',
|
||||
fields: 'id,status,prosthetic_type,clinic_tenant_id,lab_tenant_id,created,updated,due_date,price,currency,expand',
|
||||
).catchError((_) => ResultList<RecordModel>()),
|
||||
_pb.collection('finance_entries').getList(
|
||||
filter: 'tenant_id = "$tenantId"',
|
||||
perPage: 300,
|
||||
fields: 'id,amount,currency,status,created,counterparty_name',
|
||||
).catchError((_) => ResultList<RecordModel>()),
|
||||
_pb.collection('job_status_history').getList(
|
||||
filter: historyFilter,
|
||||
perPage: 100,
|
||||
expand: 'job_id',
|
||||
fields: 'id,action_type,created,note,job_id,expand',
|
||||
).catchError((_) => ResultList<RecordModel>()),
|
||||
]);
|
||||
|
||||
final jobRecords = (results[0] as ResultList<RecordModel>).items;
|
||||
final financeRecords = (results[1] as ResultList<RecordModel>).items;
|
||||
final historyRecords = (results[2] as ResultList<RecordModel>).items;
|
||||
|
||||
return _aggregate(tenantId, kind, jobRecords, financeRecords, historyRecords);
|
||||
}
|
||||
|
||||
ReportMetrics _aggregate(
|
||||
String tenantId,
|
||||
TenantKind kind,
|
||||
List<RecordModel> jobRecords,
|
||||
List<RecordModel> financeRecords,
|
||||
List<RecordModel> historyRecords,
|
||||
) {
|
||||
final now = DateTime.now();
|
||||
final thisMonthStart = DateTime(now.year, now.month, 1);
|
||||
|
||||
// ── Parse jobs ────────────────────────────────────────────────────────────
|
||||
String _s(Map<String, dynamic> j, String k) {
|
||||
final v = j[k];
|
||||
if (v == null || v == '') return '';
|
||||
return v.toString();
|
||||
}
|
||||
|
||||
final jobs = jobRecords.map((r) {
|
||||
final j = r.toJson();
|
||||
final exp = j['expand'] as Map<String, dynamic>?;
|
||||
final cpKey = kind == TenantKind.lab ? 'clinic_tenant_id' : 'lab_tenant_id';
|
||||
final cpExp = exp?[cpKey] as Map<String, dynamic>?;
|
||||
return _RawJob(
|
||||
id: _s(j, 'id'),
|
||||
status: _s(j, 'status'),
|
||||
prostheticType: _s(j, 'prosthetic_type'),
|
||||
clinicTenantId: _s(j, 'clinic_tenant_id'),
|
||||
labTenantId: _s(j, 'lab_tenant_id'),
|
||||
created: _parseDate(j['created']),
|
||||
updated: _parseDate(j['updated']),
|
||||
dueDate: j['due_date'] != null && j['due_date'] != '' ? _parseDate(j['due_date']) : null,
|
||||
currency: _s(j, 'currency').isNotEmpty ? _s(j, 'currency') : 'TRY',
|
||||
counterpartName: cpExp?['company_name'] as String? ?? '',
|
||||
);
|
||||
}).toList();
|
||||
|
||||
// ── Parse finance ─────────────────────────────────────────────────────────
|
||||
String topCurrency = 'TRY';
|
||||
final financeList = financeRecords.map((r) {
|
||||
final j = r.toJson();
|
||||
final cur = (j['currency'] as String?) ?? 'TRY';
|
||||
if (cur.isNotEmpty) topCurrency = cur;
|
||||
return _RawFinance(
|
||||
status: (j['status'] as String?) ?? '',
|
||||
amount: (j['amount'] as num?)?.toDouble() ?? 0,
|
||||
created: _parseDate(j['created']),
|
||||
counterpartyName: j['counterparty_name'] as String?,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
// ── Parse history ─────────────────────────────────────────────────────────
|
||||
final activity = historyRecords.map((r) {
|
||||
final j = r.toJson();
|
||||
final exp = j['expand'] as Map<String, dynamic>?;
|
||||
final jobExp = exp?['job_id'] as Map<String, dynamic>?;
|
||||
return ActivityItem(
|
||||
jobId: (j['job_id'] as String?) ?? '',
|
||||
patientCode: jobExp?['patient_code'] as String?,
|
||||
action: (j['action_type'] as String?) ?? '',
|
||||
createdAt: _parseDate(j['created']),
|
||||
note: j['note'] as String?,
|
||||
);
|
||||
}).toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
|
||||
// ── KPI metrics ───────────────────────────────────────────────────────────
|
||||
final activeJobs = jobs.where((j) => j.status == 'in_progress' || j.status == 'pending').length;
|
||||
final completedThisMonth = jobs.where((j) => j.status == 'delivered' && j.updated.isAfter(thisMonthStart)).length;
|
||||
final overdueJobs = jobs.where((j) =>
|
||||
j.dueDate != null &&
|
||||
j.dueDate!.isBefore(now) &&
|
||||
(j.status == 'in_progress' || j.status == 'pending')).length;
|
||||
|
||||
final revisions = activity.where((a) => a.action == 'revision_requested').length;
|
||||
final revisionRate = activity.isNotEmpty ? revisions / activity.length * 100 : 0.0;
|
||||
|
||||
final deliveredJobs = jobs.where((j) => j.status == 'delivered').toList();
|
||||
final avgCompletionDays = deliveredJobs.isNotEmpty
|
||||
? deliveredJobs
|
||||
.fold<int>(0, (s, j) => s + j.updated.difference(j.created).inDays) /
|
||||
deliveredJobs.length
|
||||
: 0.0;
|
||||
|
||||
// ── Finance totals ────────────────────────────────────────────────────────
|
||||
final totalRevenue = financeList.where((f) => f.status == 'paid').fold<double>(0, (s, f) => s + f.amount);
|
||||
final pendingRevenue = financeList.where((f) => f.status == 'pending').fold<double>(0, (s, f) => s + f.amount);
|
||||
|
||||
// ── Job status distribution ───────────────────────────────────────────────
|
||||
final Map<String, int> jobsByStatus = {};
|
||||
for (final j in jobs) {
|
||||
jobsByStatus[j.status] = (jobsByStatus[j.status] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// ── Monthly job counts (last 6 months) ────────────────────────────────────
|
||||
final monthKeys = List.generate(6, (i) {
|
||||
final d = DateTime(now.year, now.month - 5 + i, 1);
|
||||
return '${d.year}-${d.month}';
|
||||
});
|
||||
final monthMap = {for (final k in monthKeys) k: 0};
|
||||
for (final j in jobs) {
|
||||
final key = '${j.created.year}-${j.created.month}';
|
||||
if (monthMap.containsKey(key)) monthMap[key] = monthMap[key]! + 1;
|
||||
}
|
||||
final monthlyCounts = monthKeys.map((k) {
|
||||
final parts = k.split('-');
|
||||
return MonthlyCount(year: int.parse(parts[0]), month: int.parse(parts[1]), count: monthMap[k]!);
|
||||
}).toList();
|
||||
|
||||
// ── Monthly revenue (last 6 months) ──────────────────────────────────────
|
||||
final revMap = {for (final k in monthKeys) k: 0.0};
|
||||
for (final f in financeList) {
|
||||
if (f.status == 'paid') {
|
||||
final key = '${f.created.year}-${f.created.month}';
|
||||
if (revMap.containsKey(key)) revMap[key] = revMap[key]! + f.amount;
|
||||
}
|
||||
}
|
||||
final monthlyRevenue = monthKeys.map((k) {
|
||||
final parts = k.split('-');
|
||||
return MonthlyAmount(year: int.parse(parts[0]), month: int.parse(parts[1]), amount: revMap[k]!);
|
||||
}).toList();
|
||||
|
||||
// ── By prosthetic type ────────────────────────────────────────────────────
|
||||
final Map<String, int> byType = {};
|
||||
for (final j in jobs) {
|
||||
if (j.prostheticType.isNotEmpty) {
|
||||
byType[j.prostheticType] = (byType[j.prostheticType] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── By counterpart ────────────────────────────────────────────────────────
|
||||
final Map<String, int> cpCount = {};
|
||||
final Map<String, double> cpPending = {}, cpPaid = {};
|
||||
for (final j in jobs) {
|
||||
final name = j.counterpartName.isNotEmpty ? j.counterpartName : '—';
|
||||
cpCount[name] = (cpCount[name] ?? 0) + 1;
|
||||
}
|
||||
for (final f in financeList) {
|
||||
final name = f.counterpartyName ?? '—';
|
||||
if (f.status == 'pending') cpPending[name] = (cpPending[name] ?? 0) + f.amount;
|
||||
if (f.status == 'paid') cpPaid[name] = (cpPaid[name] ?? 0) + f.amount;
|
||||
}
|
||||
final counterparts = cpCount.entries
|
||||
.map((e) => CounterpartStat(
|
||||
name: e.key,
|
||||
jobCount: e.value,
|
||||
pendingRevenue: cpPending[e.key] ?? 0,
|
||||
paidRevenue: cpPaid[e.key] ?? 0,
|
||||
))
|
||||
.toList()
|
||||
..sort((a, b) => b.jobCount.compareTo(a.jobCount));
|
||||
|
||||
return ReportMetrics(
|
||||
activeJobs: activeJobs,
|
||||
completedThisMonth: completedThisMonth,
|
||||
overdueJobs: overdueJobs,
|
||||
revisionRate: revisionRate,
|
||||
avgCompletionDays: avgCompletionDays,
|
||||
totalRevenue: totalRevenue,
|
||||
pendingRevenue: pendingRevenue,
|
||||
currency: topCurrency,
|
||||
jobsByStatus: jobsByStatus,
|
||||
monthlyCounts: monthlyCounts,
|
||||
monthlyRevenue: monthlyRevenue,
|
||||
byProstheticType: byType,
|
||||
counterpartStats: counterparts.take(5).toList(),
|
||||
recentActivity: activity.take(30).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
static DateTime _parseDate(dynamic v) {
|
||||
if (v == null || v == '') return DateTime(2000);
|
||||
return DateTime.tryParse(v.toString()) ?? DateTime(2000);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal raw models ───────────────────────────────────────────────────────
|
||||
|
||||
class _RawJob {
|
||||
const _RawJob({
|
||||
required this.id, required this.status, required this.prostheticType,
|
||||
required this.clinicTenantId, required this.labTenantId,
|
||||
required this.created, required this.updated,
|
||||
required this.currency, required this.counterpartName, this.dueDate,
|
||||
});
|
||||
final String id, status, prostheticType, clinicTenantId, labTenantId, currency, counterpartName;
|
||||
final DateTime created, updated;
|
||||
final DateTime? dueDate;
|
||||
}
|
||||
|
||||
class _RawFinance {
|
||||
const _RawFinance({required this.status, required this.amount, required this.created, this.counterpartyName});
|
||||
final String status;
|
||||
final double amount;
|
||||
final DateTime created;
|
||||
final String? counterpartyName;
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../core/providers/auth_provider.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/gradient_app_bar.dart';
|
||||
import '../../models/job.dart';
|
||||
import '../../models/tenant.dart';
|
||||
import 'reports_repository.dart';
|
||||
|
||||
class ReportsScreen extends ConsumerStatefulWidget {
|
||||
const ReportsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ReportsScreen> createState() => _ReportsScreenState();
|
||||
}
|
||||
|
||||
class _ReportsScreenState extends ConsumerState<ReportsScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
late Future<ReportMetrics> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 4, vsync: this);
|
||||
_tabController.addListener(() { if (mounted) setState(() {}); });
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
final auth = ref.read(authProvider);
|
||||
final tenantId = auth.activeTenant!.tenant.id;
|
||||
final kind = auth.activeTenant!.tenant.kind;
|
||||
setState(() {
|
||||
_future = ReportsRepository.instance.load(tenantId, kind);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kind = ref.watch(authProvider).activeTenant?.tenant.kind ?? TenantKind.lab;
|
||||
final counterpartLabel = kind == TenantKind.lab ? 'Klinikler' : 'Laboratuvarlar';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: GradientAppBar(
|
||||
title: 'Raporlar',
|
||||
category: 'YÖNETİCİ',
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
onPressed: _load,
|
||||
tooltip: 'Yenile',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: FutureBuilder<ReportMetrics>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator(color: AppColors.accent));
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline_rounded, color: AppColors.cancelled, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
Text('Veriler yüklenemedi', style: const TextStyle(color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('Tekrar Dene'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final m = snap.data!;
|
||||
return Column(
|
||||
children: [
|
||||
// Tab bar
|
||||
Container(
|
||||
color: AppColors.surface,
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: AppColors.accent,
|
||||
unselectedLabelColor: AppColors.textSecondary,
|
||||
indicatorColor: AppColors.accent,
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
labelStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||
tabs: [
|
||||
const Tab(text: 'Özet'),
|
||||
const Tab(text: 'Finans'),
|
||||
const Tab(text: 'Aktivite'),
|
||||
Tab(text: counterpartLabel),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_SummaryTab(metrics: m),
|
||||
_FinanceTab(metrics: m),
|
||||
_ActivityTab(metrics: m),
|
||||
_CounterpartTab(metrics: m, label: counterpartLabel),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared layout helpers ─────────────────────────────────────────────────────
|
||||
|
||||
class _TabBody extends StatelessWidget {
|
||||
const _TabBody({required this.children});
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
class _Card extends StatelessWidget {
|
||||
const _Card({required this.child, this.padding = const EdgeInsets.all(16)});
|
||||
final Widget child;
|
||||
final EdgeInsets padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 8, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader(this.title, {this.subtitle});
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10, top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
||||
),
|
||||
if (subtitle != null)
|
||||
Text(subtitle!, style: const TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── KPI Chips ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class _KpiRow extends StatelessWidget {
|
||||
const _KpiRow({required this.metrics});
|
||||
final ReportMetrics metrics;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fmt = NumberFormat.currency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0);
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
_Kpi(label: 'Aktif İşler', value: '${metrics.activeJobs}', icon: Icons.work_outline_rounded, color: AppColors.inProgress),
|
||||
const SizedBox(width: 10),
|
||||
_Kpi(label: 'Bu Ay Tamamlandı', value: '${metrics.completedThisMonth}', icon: Icons.check_circle_outline_rounded, color: AppColors.success),
|
||||
const SizedBox(width: 10),
|
||||
_Kpi(label: 'Bekleyen Gelir', value: fmt.format(metrics.pendingRevenue), icon: Icons.hourglass_empty_rounded, color: AppColors.pending),
|
||||
const SizedBox(width: 10),
|
||||
_Kpi(label: 'Ort. Süre', value: '${metrics.avgCompletionDays.toStringAsFixed(1)} gün', icon: Icons.timer_outlined, color: AppColors.accent),
|
||||
const SizedBox(width: 10),
|
||||
_Kpi(label: 'Revizyon Oranı', value: '%${metrics.revisionRate.toStringAsFixed(0)}', icon: Icons.loop_rounded, color: metrics.revisionRate > 20 ? AppColors.cancelled : AppColors.textSecondary),
|
||||
if (metrics.overdueJobs > 0) ...[
|
||||
const SizedBox(width: 10),
|
||||
_Kpi(label: 'Gecikmiş', value: '${metrics.overdueJobs}', icon: Icons.schedule_rounded, color: AppColors.cancelled),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Kpi extends StatelessWidget {
|
||||
const _Kpi({required this.label, required this.value, required this.icon, required this.color});
|
||||
final String label, value;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 6)],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textMuted)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(value, style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: color)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Özet Tab ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class _SummaryTab extends StatelessWidget {
|
||||
const _SummaryTab({required this.metrics});
|
||||
final ReportMetrics metrics;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _TabBody(children: [
|
||||
_KpiRow(metrics: metrics),
|
||||
const _SectionHeader('İş Durumu Dağılımı'),
|
||||
_Card(
|
||||
child: Column(
|
||||
children: _statusOrder.where((s) => metrics.jobsByStatus.containsKey(s)).map((s) {
|
||||
final count = metrics.jobsByStatus[s] ?? 0;
|
||||
final total = metrics.jobsByStatus.values.fold(0, (a, b) => a + b);
|
||||
return _HBarRow(
|
||||
label: _statusLabel(s),
|
||||
value: count,
|
||||
max: total,
|
||||
color: _statusColor(s),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const _SectionHeader('Son 6 Aylık İş Trendi'),
|
||||
_Card(child: _VBarChart(data: metrics.monthlyCounts, color: AppColors.accent)),
|
||||
]);
|
||||
}
|
||||
|
||||
static const _statusOrder = ['in_progress', 'pending', 'sent', 'delivered', 'cancelled'];
|
||||
|
||||
static String _statusLabel(String s) => switch (s) {
|
||||
'pending' => 'Bekliyor',
|
||||
'in_progress' => 'İşlemde',
|
||||
'sent' => 'Gönderildi',
|
||||
'delivered' => 'Teslim',
|
||||
'cancelled' => 'İptal',
|
||||
_ => s,
|
||||
};
|
||||
|
||||
static Color _statusColor(String s) => switch (s) {
|
||||
'pending' => AppColors.pending,
|
||||
'in_progress' => AppColors.inProgress,
|
||||
'sent' => AppColors.accent,
|
||||
'delivered' => AppColors.success,
|
||||
'cancelled' => AppColors.cancelled,
|
||||
_ => AppColors.textMuted,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Finans Tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _FinanceTab extends StatelessWidget {
|
||||
const _FinanceTab({required this.metrics});
|
||||
final ReportMetrics metrics;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fmt = NumberFormat.currency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0);
|
||||
final total = metrics.totalRevenue + metrics.pendingRevenue;
|
||||
return _TabBody(children: [
|
||||
const _SectionHeader('Gelir Özeti'),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Container(width: 8, height: 8, decoration: BoxDecoration(color: AppColors.success, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
const Text('Tahsil Edildi', style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
]),
|
||||
const SizedBox(height: 6),
|
||||
Text(fmt.format(metrics.totalRevenue),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.success)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Container(width: 8, height: 8, decoration: BoxDecoration(color: AppColors.pending, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
const Text('Bekleyen', style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
]),
|
||||
const SizedBox(height: 6),
|
||||
Text(fmt.format(metrics.pendingRevenue),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.pending)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (total > 0) _Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Tahsilat Oranı', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 10),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: LinearProgressIndicator(
|
||||
value: total > 0 ? metrics.totalRevenue / total : 0,
|
||||
minHeight: 12,
|
||||
backgroundColor: AppColors.pendingBg,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.success),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text('${(metrics.totalRevenue / total * 100).toStringAsFixed(0)}% tahsil edildi',
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const _SectionHeader('Aylık Gelir Trendi'),
|
||||
_Card(child: _VBarChart(
|
||||
data: metrics.monthlyRevenue.map((m) => MonthlyCount(year: m.year, month: m.month, count: m.amount.round())).toList(),
|
||||
color: AppColors.success,
|
||||
formatValue: (v) => NumberFormat.compactCurrency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0).format(v),
|
||||
)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aktivite Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _ActivityTab extends StatelessWidget {
|
||||
const _ActivityTab({required this.metrics});
|
||||
final ReportMetrics metrics;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = metrics.recentActivity;
|
||||
if (items.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Henüz aktivite kaydı yok.', style: TextStyle(color: AppColors.textMuted)),
|
||||
);
|
||||
}
|
||||
return _TabBody(children: [
|
||||
const _SectionHeader('Son İşlemler'),
|
||||
_Card(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Column(
|
||||
children: items.asMap().entries.map((entry) {
|
||||
final i = entry.key;
|
||||
final item = entry.value;
|
||||
return _ActivityRow(item: item, isLast: i == items.length - 1);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActivityRow extends StatelessWidget {
|
||||
const _ActivityRow({required this.item, required this.isLast});
|
||||
final ActivityItem item;
|
||||
final bool isLast;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = item.isNegative ? AppColors.cancelled : item.isPositive ? AppColors.success : AppColors.accent;
|
||||
final icon = switch (item.action) {
|
||||
'accepted' => Icons.check_circle_outline_rounded,
|
||||
'handed_to_clinic' => Icons.send_rounded,
|
||||
'approved' => Icons.thumb_up_outlined,
|
||||
'revision_requested' => Icons.loop_rounded,
|
||||
'delivered' => Icons.local_shipping_outlined,
|
||||
'cancelled' => Icons.cancel_outlined,
|
||||
_ => Icons.history_rounded,
|
||||
};
|
||||
final df = DateFormat('dd.MM.yy HH:mm');
|
||||
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Timeline line + dot
|
||||
SizedBox(
|
||||
width: 28,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 24, height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, size: 13, color: color),
|
||||
),
|
||||
if (!isLast)
|
||||
Expanded(child: Container(width: 1.5, color: AppColors.border)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: isLast ? 0 : 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(item.actionLabel,
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
if (item.patientCode != null && item.patientCode!.isNotEmpty)
|
||||
Text(item.patientCode!, style: const TextStyle(fontSize: 11, color: AppColors.accent)),
|
||||
if (item.note != null && item.note!.isNotEmpty)
|
||||
Text(item.note!, style: const TextStyle(fontSize: 11, color: AppColors.textMuted)),
|
||||
const SizedBox(height: 2),
|
||||
Text(df.format(item.createdAt), style: const TextStyle(fontSize: 10, color: AppColors.textMuted)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Counterpart Tab ───────────────────────────────────────────────────────────
|
||||
|
||||
class _CounterpartTab extends StatelessWidget {
|
||||
const _CounterpartTab({required this.metrics, required this.label});
|
||||
final ReportMetrics metrics;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final stats = metrics.counterpartStats;
|
||||
if (stats.isEmpty) {
|
||||
return Center(
|
||||
child: Text('$label henüz yok.', style: const TextStyle(color: AppColors.textMuted)),
|
||||
);
|
||||
}
|
||||
final fmt = NumberFormat.currency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0);
|
||||
final maxJobs = stats.fold(0, (m, s) => s.jobCount > m ? s.jobCount : m);
|
||||
|
||||
return _TabBody(children: [
|
||||
const _SectionHeader('Protez Türü Dağılımı'),
|
||||
_Card(
|
||||
child: Column(
|
||||
children: _buildTypeRows(metrics.byProstheticType),
|
||||
),
|
||||
),
|
||||
_SectionHeader('En Aktif $label'),
|
||||
_Card(
|
||||
child: Column(
|
||||
children: stats.asMap().entries.map((entry) {
|
||||
final i = entry.key;
|
||||
final s = entry.value;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: i < stats.length - 1 ? 12 : 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 22, height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text('${i + 1}',
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.inProgress)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(s.name,
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
Text('${s.jobCount} iş',
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary, fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: maxJobs > 0 ? s.jobCount / maxJobs : 0,
|
||||
minHeight: 6,
|
||||
backgroundColor: AppColors.surfaceVariant,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.accent),
|
||||
),
|
||||
),
|
||||
if (s.totalRevenue > 0) Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'${fmt.format(s.paidRevenue)} tahsil · ${fmt.format(s.pendingRevenue)} bekliyor',
|
||||
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
List<Widget> _buildTypeRows(Map<String, int> byType) {
|
||||
if (byType.isEmpty) return [const Text('Veri yok', style: TextStyle(color: AppColors.textMuted))];
|
||||
final sorted = byType.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
|
||||
final max = sorted.first.value;
|
||||
return sorted.map((e) => _HBarRow(
|
||||
label: _typeLabel(e.key),
|
||||
value: e.value,
|
||||
max: max,
|
||||
color: AppColors.accent,
|
||||
)).toList();
|
||||
}
|
||||
|
||||
static String _typeLabel(String s) => switch (s) {
|
||||
'metal_porselen' => 'Metal Porselen',
|
||||
'zirkonyum' => 'Zirkonyum',
|
||||
'implant_ustu_zirkonyum'=> 'İmplant Üstü',
|
||||
'gecici' => 'Geçici',
|
||||
'e_max' => 'E-Max',
|
||||
'tam_protez' => 'Tam Protez',
|
||||
'parsiyel' => 'Parsiyel',
|
||||
_ => 'Diğer',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Chart widgets ─────────────────────────────────────────────────────────────
|
||||
|
||||
class _HBarRow extends StatelessWidget {
|
||||
const _HBarRow({required this.label, required this.value, required this.max, required this.color});
|
||||
final String label;
|
||||
final int value, max;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fraction = max > 0 ? value / max : 0.0;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(label,
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(height: 22, color: AppColors.surfaceVariant),
|
||||
FractionallySizedBox(
|
||||
widthFactor: fraction,
|
||||
child: Container(
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.8),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 28,
|
||||
child: Text('$value',
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.textPrimary),
|
||||
textAlign: TextAlign.right),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VBarChart extends StatelessWidget {
|
||||
const _VBarChart({required this.data, required this.color, this.formatValue});
|
||||
final List<MonthlyCount> data;
|
||||
final Color color;
|
||||
final String Function(int)? formatValue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (data.isEmpty) return const SizedBox.shrink();
|
||||
final maxVal = data.fold(0, (m, e) => e.count > m ? e.count : m);
|
||||
final fmt = formatValue ?? (v) => '$v';
|
||||
|
||||
return SizedBox(
|
||||
height: 140,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: data.map((d) {
|
||||
final fraction = maxVal > 0 ? d.count / maxVal : 0.0;
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (d.count > 0)
|
||||
Text(fmt(d.count),
|
||||
style: const TextStyle(fontSize: 9, color: AppColors.textMuted),
|
||||
textAlign: TextAlign.center),
|
||||
const SizedBox(height: 2),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
curve: Curves.easeOut,
|
||||
height: (fraction * 90).clamp(2, 90),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(d.label,
|
||||
style: const TextStyle(fontSize: 10, color: AppColors.textMuted),
|
||||
textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../core/api/pocketbase_client.dart';
|
||||
import '../../models/tenant.dart';
|
||||
import '../../models/user_profile.dart';
|
||||
|
||||
class TeamMember {
|
||||
const TeamMember({
|
||||
required this.memberId,
|
||||
required this.user,
|
||||
required this.role,
|
||||
required this.joinedAt,
|
||||
});
|
||||
final String memberId;
|
||||
final UserProfile user;
|
||||
final TenantRole role;
|
||||
final DateTime joinedAt;
|
||||
}
|
||||
|
||||
class TenantTeamRepository {
|
||||
TenantTeamRepository._();
|
||||
static final instance = TenantTeamRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<List<TeamMember>> listMembers(String tenantId) async {
|
||||
final result = await _pb.collection('tenant_members').getList(
|
||||
filter: 'tenant_id = "$tenantId"',
|
||||
expand: 'user_id',
|
||||
perPage: 200,
|
||||
);
|
||||
return (result.items.map((r) {
|
||||
final j = r.toJson();
|
||||
final userExp = (j['expand'] as Map?)?['user_id'] as Map<String, dynamic>?;
|
||||
return TeamMember(
|
||||
memberId: j['id'] as String,
|
||||
user: UserProfile.fromJson(userExp ?? {'id': j['user_id'], 'email': ''}),
|
||||
role: TenantMembership.parseRole(j['role'] as String),
|
||||
joinedAt: DateTime.parse(j['created'] as String),
|
||||
);
|
||||
}).toList()..sort((a, b) => a.joinedAt.compareTo(b.joinedAt)));
|
||||
}
|
||||
|
||||
Future<TeamMember> addMember({
|
||||
required String tenantId,
|
||||
required String email,
|
||||
required String password,
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
required TenantRole role,
|
||||
}) async {
|
||||
final userRecord = await _pb.collection('users').create(body: {
|
||||
'email': email.trim().toLowerCase(),
|
||||
'password': password,
|
||||
'passwordConfirm': password,
|
||||
'first_name': firstName.trim(),
|
||||
'last_name': lastName.trim(),
|
||||
'emailVisibility': true,
|
||||
});
|
||||
final memberRecord = await _pb.collection('tenant_members').create(body: {
|
||||
'tenant_id': tenantId,
|
||||
'user_id': userRecord.id,
|
||||
'role': role.value,
|
||||
});
|
||||
final j = memberRecord.toJson();
|
||||
return TeamMember(
|
||||
memberId: j['id'] as String,
|
||||
user: UserProfile.fromJson(userRecord.toJson()),
|
||||
role: role,
|
||||
joinedAt: DateTime.parse(j['created'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> changeMemberRole(String memberId, TenantRole newRole) async {
|
||||
await _pb.collection('tenant_members').update(memberId, body: {
|
||||
'role': newRole.value,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> removeMember(String memberId) async {
|
||||
await _pb.collection('tenant_members').delete(memberId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/providers/auth_provider.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/tenant.dart';
|
||||
import 'tenant_team_repository.dart';
|
||||
|
||||
class TenantTeamScreen extends ConsumerStatefulWidget {
|
||||
const TenantTeamScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<TenantTeamScreen> createState() => _TenantTeamScreenState();
|
||||
}
|
||||
|
||||
class _TenantTeamScreenState extends ConsumerState<TenantTeamScreen> {
|
||||
List<TeamMember> _members = [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
final members = await TenantTeamRepository.instance.listMembers(tenantId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_members = members;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool get _canManage =>
|
||||
ref.read(authProvider).activeTenant?.canManageUsers ?? false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Ekip'),
|
||||
actions: [
|
||||
if (_canManage)
|
||||
TextButton.icon(
|
||||
onPressed: () => _showAddMemberSheet(context),
|
||||
icon: const Icon(Icons.person_add_outlined, size: 18),
|
||||
label: const Text('Üye Ekle'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? _ErrorView(error: _error!, onRetry: _load)
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_SectionHeader(
|
||||
title: 'Üyeler',
|
||||
count: _members.length,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_MembersList(
|
||||
members: _members,
|
||||
canManage: _canManage,
|
||||
currentUserId:
|
||||
ref.read(authProvider).profile?.id ?? '',
|
||||
onRoleChange: _changeRole,
|
||||
onRemove: _removeMember,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showAddMemberSheet(BuildContext context) async {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: AppColors.background,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (_) => _AddMemberSheet(
|
||||
onAdd: (firstName, lastName, email, password, role) async {
|
||||
await TenantTeamRepository.instance.addMember(
|
||||
tenantId: tenantId,
|
||||
email: email,
|
||||
password: password,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
role: role,
|
||||
);
|
||||
await _load();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _changeRole(TeamMember member, TenantRole newRole) async {
|
||||
try {
|
||||
await TenantTeamRepository.instance.changeMemberRole(
|
||||
member.memberId, newRole);
|
||||
await _load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeMember(TeamMember member) async {
|
||||
final name = member.user.displayName.isNotEmpty
|
||||
? member.user.displayName
|
||||
: member.user.email;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Üyeyi Çıkar'),
|
||||
content: Text('$name adlı üyeyi ekipten çıkarmak istiyor musunuz?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Vazgeç')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
|
||||
child: const Text('Çıkar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
await TenantTeamRepository.instance.removeMember(member.memberId);
|
||||
await _load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section header ─────────────────────────────────────────────────────────
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.title, required this.count});
|
||||
final String title;
|
||||
final int count;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textSecondary,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.inProgress,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Members list ───────────────────────────────────────────────────────────
|
||||
|
||||
class _MembersList extends StatelessWidget {
|
||||
const _MembersList({
|
||||
required this.members,
|
||||
required this.canManage,
|
||||
required this.currentUserId,
|
||||
required this.onRoleChange,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
final List<TeamMember> members;
|
||||
final bool canManage;
|
||||
final String currentUserId;
|
||||
final Future<void> Function(TeamMember, TenantRole) onRoleChange;
|
||||
final Future<void> Function(TeamMember) onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (members.isEmpty) {
|
||||
return const _EmptyCard(message: 'Henüz üye yok.');
|
||||
}
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: members.asMap().entries.map((entry) {
|
||||
final i = entry.key;
|
||||
final m = entry.value;
|
||||
final isLast = i == members.length - 1;
|
||||
return _MemberTile(
|
||||
member: m,
|
||||
isSelf: m.user.id == currentUserId,
|
||||
canManage: canManage && m.role != TenantRole.owner,
|
||||
showDivider: !isLast,
|
||||
onRoleChange: (role) => onRoleChange(m, role),
|
||||
onRemove: () => onRemove(m),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MemberTile extends StatelessWidget {
|
||||
const _MemberTile({
|
||||
required this.member,
|
||||
required this.isSelf,
|
||||
required this.canManage,
|
||||
required this.showDivider,
|
||||
required this.onRoleChange,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
final TeamMember member;
|
||||
final bool isSelf;
|
||||
final bool canManage;
|
||||
final bool showDivider;
|
||||
final void Function(TenantRole) onRoleChange;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final name = member.user.displayName.isNotEmpty
|
||||
? member.user.displayName
|
||||
: member.user.email;
|
||||
final initials = name.trim().isNotEmpty
|
||||
? name.trim()[0].toUpperCase()
|
||||
: '?';
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
initials,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.inProgress,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
if (isSelf) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.successBg,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text(
|
||||
'Sen',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.success,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
Text(
|
||||
member.user.email,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (canManage) ...[
|
||||
_RoleChip(
|
||||
role: member.role,
|
||||
onChanged: onRoleChange,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
onPressed: onRemove,
|
||||
icon: const Icon(Icons.remove_circle_outline,
|
||||
color: AppColors.cancelled, size: 20),
|
||||
tooltip: 'Çıkar',
|
||||
constraints: const BoxConstraints(),
|
||||
padding: const EdgeInsets.all(6),
|
||||
),
|
||||
] else
|
||||
_RoleBadge(role: member.role),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
const Divider(height: 1, indent: 68, color: AppColors.border),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoleChip extends StatelessWidget {
|
||||
const _RoleChip({required this.role, required this.onChanged});
|
||||
final TenantRole role;
|
||||
final void Function(TenantRole) onChanged;
|
||||
|
||||
static const _selectableRoles = [
|
||||
TenantRole.admin,
|
||||
TenantRole.technician,
|
||||
TenantRole.delivery,
|
||||
TenantRole.finance,
|
||||
TenantRole.doctor,
|
||||
TenantRole.member,
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopupMenuButton<TenantRole>(
|
||||
initialValue: role,
|
||||
onSelected: onChanged,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
itemBuilder: (_) => _selectableRoles
|
||||
.map((r) => PopupMenuItem(
|
||||
value: r,
|
||||
child: Text(r.label),
|
||||
))
|
||||
.toList(),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: _roleBg(role),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
role.label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _roleColor(role),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.arrow_drop_down, size: 16, color: _roleColor(role)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoleBadge extends StatelessWidget {
|
||||
const _RoleBadge({required this.role});
|
||||
final TenantRole role;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: _roleBg(role),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
role.label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _roleColor(role),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Color _roleBg(TenantRole r) => switch (r) {
|
||||
TenantRole.owner => AppColors.inProgressBg,
|
||||
TenantRole.admin => AppColors.inProgressBg,
|
||||
TenantRole.doctor => AppColors.successBg,
|
||||
_ => AppColors.surface,
|
||||
};
|
||||
|
||||
Color _roleColor(TenantRole r) => switch (r) {
|
||||
TenantRole.owner => AppColors.inProgress,
|
||||
TenantRole.admin => AppColors.inProgress,
|
||||
TenantRole.doctor => AppColors.success,
|
||||
_ => AppColors.textSecondary,
|
||||
};
|
||||
|
||||
// ── Add member sheet ────────────────────────────────────────────────────────
|
||||
|
||||
class _AddMemberSheet extends StatefulWidget {
|
||||
const _AddMemberSheet({required this.onAdd});
|
||||
final Future<void> Function(
|
||||
String firstName,
|
||||
String lastName,
|
||||
String email,
|
||||
String password,
|
||||
TenantRole role,
|
||||
) onAdd;
|
||||
|
||||
@override
|
||||
State<_AddMemberSheet> createState() => _AddMemberSheetState();
|
||||
}
|
||||
|
||||
class _AddMemberSheetState extends State<_AddMemberSheet> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _firstNameController = TextEditingController();
|
||||
final _lastNameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
TenantRole _selectedRole = TenantRole.member;
|
||||
bool _saving = false;
|
||||
bool _obscurePassword = true;
|
||||
|
||||
static const _selectableRoles = [
|
||||
TenantRole.admin,
|
||||
TenantRole.technician,
|
||||
TenantRole.delivery,
|
||||
TenantRole.finance,
|
||||
TenantRole.doctor,
|
||||
TenantRole.member,
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_firstNameController.dispose();
|
||||
_lastNameController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await widget.onAdd(
|
||||
_firstNameController.text.trim(),
|
||||
_lastNameController.text.trim(),
|
||||
_emailController.text.trim(),
|
||||
_passwordController.text,
|
||||
_selectedRole,
|
||||
);
|
||||
if (mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
final msg = _friendlyError(e);
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
static String _friendlyError(Object e) {
|
||||
final s = e.toString();
|
||||
if (s.contains('email') && s.contains('unique')) {
|
||||
return 'Bu e-posta adresi zaten kayıtlı.';
|
||||
}
|
||||
final msgMatch = RegExp(r'message: ([^,}]+)').firstMatch(s);
|
||||
if (msgMatch != null) return msgMatch.group(1)!.trim();
|
||||
if (s.length > 120) return 'Sunucu hatası';
|
||||
return s;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottom = MediaQuery.of(context).viewInsets.bottom;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(24, 24, 24, 24 + bottom),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Üye Ekle',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _firstNameController,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'İsim',
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
),
|
||||
validator: (val) {
|
||||
if (val == null || val.trim().isEmpty) return 'Zorunlu';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _lastNameController,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Soyisim',
|
||||
),
|
||||
validator: (val) {
|
||||
if (val == null || val.trim().isEmpty) return 'Zorunlu';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
autocorrect: false,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'E-posta',
|
||||
hintText: 'ornek@email.com',
|
||||
prefixIcon: Icon(Icons.email_outlined),
|
||||
),
|
||||
validator: (val) {
|
||||
if (val == null || val.trim().isEmpty) return 'E-posta zorunludur';
|
||||
if (!val.contains('@')) return 'Geçerli bir e-posta girin';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Şifre',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined),
|
||||
onPressed: () =>
|
||||
setState(() => _obscurePassword = !_obscurePassword),
|
||||
),
|
||||
),
|
||||
validator: (val) {
|
||||
if (val == null || val.isEmpty) return 'Şifre zorunludur';
|
||||
if (val.length < 8) return 'En az 8 karakter olmalı';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<TenantRole>(
|
||||
value: _selectedRole,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Rol',
|
||||
prefixIcon: Icon(Icons.badge_outlined),
|
||||
),
|
||||
items: _selectableRoles
|
||||
.map((r) => DropdownMenuItem(
|
||||
value: r,
|
||||
child: Text(r.label),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v != null) setState(() => _selectedRole = v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _saving ? null : _submit,
|
||||
icon: _saving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.person_add_outlined, size: 18),
|
||||
label: const Text('Üye Ekle'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _EmptyCard extends StatelessWidget {
|
||||
const _EmptyCard({required this.message});
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorView extends StatelessWidget {
|
||||
const _ErrorView({required this.error, required this.onRetry});
|
||||
final String error;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: AppColors.cancelled, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
Text(error,
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
textAlign: TextAlign.center),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(onPressed: onRetry, child: const Text('Tekrar Dene')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'core/api/pocketbase_client.dart';
|
||||
import 'core/providers/locale_provider.dart';
|
||||
import 'core/router/router_provider.dart';
|
||||
import 'core/services/notification_service.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await PocketBaseClient.init();
|
||||
await NotificationService.init();
|
||||
|
||||
final initialLocale = await LocaleNotifier.load();
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
localeProvider.overrideWith(
|
||||
(ref) => LocaleNotifier(initialLocale),
|
||||
),
|
||||
],
|
||||
child: const DlsApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class DlsApp extends ConsumerWidget {
|
||||
const DlsApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(routerProvider);
|
||||
final locale = ref.watch(localeProvider);
|
||||
NotificationService.setRouter(router);
|
||||
return ShadcnApp.router(
|
||||
title: 'DLS',
|
||||
debugShowCheckedModeBanner: false,
|
||||
locale: locale,
|
||||
supportedLocales: supportedLocales,
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
theme: const ThemeData(
|
||||
colorScheme: _dlsLight,
|
||||
radius: 0.5,
|
||||
),
|
||||
darkTheme: const ThemeData.dark(
|
||||
colorScheme: _dlsDark,
|
||||
radius: 0.5,
|
||||
),
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── DLS Light color scheme ────────────────────────────────────────────────────
|
||||
|
||||
const _dlsLight = ColorScheme(
|
||||
brightness: Brightness.light,
|
||||
background: Color(0xFFF1F5F9),
|
||||
foreground: Color(0xFF0F172A),
|
||||
card: Color(0xFFFFFFFF),
|
||||
cardForeground: Color(0xFF0F172A),
|
||||
popover: Color(0xFFFFFFFF),
|
||||
popoverForeground: Color(0xFF0F172A),
|
||||
primary: Color(0xFF1E3A5F),
|
||||
primaryForeground: Color(0xFFFFFFFF),
|
||||
secondary: Color(0xFFE2E8F0),
|
||||
secondaryForeground: Color(0xFF0F172A),
|
||||
muted: Color(0xFFE2E8F0),
|
||||
mutedForeground: Color(0xFF64748B),
|
||||
accent: Color(0xFFF8FAFC),
|
||||
accentForeground: Color(0xFF0F172A),
|
||||
destructive: Color(0xFFDC2626),
|
||||
border: Color(0xFFE2E8F0),
|
||||
input: Color(0xFFE2E8F0),
|
||||
ring: Color(0xFF0369A1),
|
||||
chart1: Color(0xFF1E3A5F),
|
||||
chart2: Color(0xFF059669),
|
||||
chart3: Color(0xFFF59E0B),
|
||||
chart4: Color(0xFF0369A1),
|
||||
chart5: Color(0xFFDC2626),
|
||||
sidebar: Color(0xFFFFFFFF),
|
||||
sidebarForeground: Color(0xFF0F172A),
|
||||
sidebarPrimary: Color(0xFF1E3A5F),
|
||||
sidebarPrimaryForeground: Color(0xFFFFFFFF),
|
||||
sidebarAccent: Color(0xFFF1F5F9),
|
||||
sidebarAccentForeground: Color(0xFF0F172A),
|
||||
sidebarBorder: Color(0xFFE2E8F0),
|
||||
sidebarRing: Color(0xFF0369A1),
|
||||
);
|
||||
|
||||
// ── DLS Dark color scheme ─────────────────────────────────────────────────────
|
||||
|
||||
const _dlsDark = ColorScheme(
|
||||
brightness: Brightness.dark,
|
||||
background: Color(0xFF0F172A),
|
||||
foreground: Color(0xFFF1F5F9),
|
||||
card: Color(0xFF1E293B),
|
||||
cardForeground: Color(0xFFF1F5F9),
|
||||
popover: Color(0xFF1E293B),
|
||||
popoverForeground: Color(0xFFF1F5F9),
|
||||
primary: Color(0xFF93C5FD),
|
||||
primaryForeground: Color(0xFF1E3A5F),
|
||||
secondary: Color(0xFF273344),
|
||||
secondaryForeground: Color(0xFFF1F5F9),
|
||||
muted: Color(0xFF273344),
|
||||
mutedForeground: Color(0xFF94A3B8),
|
||||
accent: Color(0xFF273344),
|
||||
accentForeground: Color(0xFFF1F5F9),
|
||||
destructive: Color(0xFFFCA5A5),
|
||||
border: Color(0xFF334155),
|
||||
input: Color(0xFF334155),
|
||||
ring: Color(0xFF7DD3FC),
|
||||
chart1: Color(0xFF93C5FD),
|
||||
chart2: Color(0xFF6EE7B7),
|
||||
chart3: Color(0xFFFCD34D),
|
||||
chart4: Color(0xFF7DD3FC),
|
||||
chart5: Color(0xFFFCA5A5),
|
||||
sidebar: Color(0xFF1E293B),
|
||||
sidebarForeground: Color(0xFFF1F5F9),
|
||||
sidebarPrimary: Color(0xFF93C5FD),
|
||||
sidebarPrimaryForeground: Color(0xFF1E3A5F),
|
||||
sidebarAccent: Color(0xFF273344),
|
||||
sidebarAccentForeground: Color(0xFFF1F5F9),
|
||||
sidebarBorder: Color(0xFF334155),
|
||||
sidebarRing: Color(0xFF7DD3FC),
|
||||
);
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'job.dart';
|
||||
|
||||
enum DiscountType { percentage, fixed }
|
||||
|
||||
extension DiscountTypeX on DiscountType {
|
||||
String get value => name;
|
||||
String get label => this == DiscountType.percentage ? 'Yüzde (%)' : 'Sabit Tutar (TL)';
|
||||
}
|
||||
|
||||
class ClinicDiscount {
|
||||
const ClinicDiscount({
|
||||
required this.id,
|
||||
required this.labTenantId,
|
||||
this.clinicTenantId,
|
||||
this.clinicName,
|
||||
this.prostheticType,
|
||||
required this.discountType,
|
||||
required this.discountValue,
|
||||
this.minQuantity = 0,
|
||||
required this.isActive,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String labTenantId;
|
||||
final String? clinicTenantId; // null = tüm klinikler
|
||||
final String? clinicName;
|
||||
final String? prostheticType; // null = tüm ürün tipleri
|
||||
final DiscountType discountType;
|
||||
final double discountValue;
|
||||
final int minQuantity; // 0 = minimum yok
|
||||
final bool isActive;
|
||||
final String? notes;
|
||||
|
||||
bool get appliesToAll => clinicTenantId == null || clinicTenantId!.isEmpty;
|
||||
bool get appliesToAllTypes => prostheticType == null || prostheticType!.isEmpty;
|
||||
|
||||
String get displayValue => discountType == DiscountType.percentage
|
||||
? '%${discountValue.toStringAsFixed(discountValue % 1 == 0 ? 0 : 1)}'
|
||||
: '${discountValue.toStringAsFixed(2)} TL';
|
||||
|
||||
String get prostheticLabel {
|
||||
if (appliesToAllTypes) return 'Tüm Türler';
|
||||
return ProstheticType.values
|
||||
.firstWhere((e) => e.value == prostheticType,
|
||||
orElse: () => ProstheticType.diger)
|
||||
.label;
|
||||
}
|
||||
|
||||
factory ClinicDiscount.fromJson(Map<String, dynamic> j) {
|
||||
final expand = j['expand'] as Map<String, dynamic>?;
|
||||
final clinicExp = expand?['clinic_tenant_id'] as Map<String, dynamic>?;
|
||||
final dt = j['discount_type'] as String? ?? 'percentage';
|
||||
final pt = j['prosthetic_type'] as String?;
|
||||
return ClinicDiscount(
|
||||
id: j['id'] as String,
|
||||
labTenantId: j['lab_tenant_id'] as String,
|
||||
clinicTenantId: j['clinic_tenant_id'] as String?,
|
||||
clinicName: clinicExp?['company_name'] as String?,
|
||||
prostheticType: (pt == null || pt.isEmpty) ? null : pt,
|
||||
discountType: dt == 'fixed' ? DiscountType.fixed : DiscountType.percentage,
|
||||
discountValue: (j['discount_value'] as num?)?.toDouble() ?? 0,
|
||||
minQuantity: (j['min_quantity'] as num?)?.toInt() ?? 0,
|
||||
isActive: j['is_active'] as bool? ?? true,
|
||||
notes: j['notes'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
enum ConnectionStatus { pending, approved, rejected }
|
||||
|
||||
extension ConnectionStatusX on ConnectionStatus {
|
||||
String get value => name;
|
||||
String get label {
|
||||
switch (this) {
|
||||
case ConnectionStatus.pending:
|
||||
return 'Bekliyor';
|
||||
case ConnectionStatus.approved:
|
||||
return 'Onaylı';
|
||||
case ConnectionStatus.rejected:
|
||||
return 'Reddedildi';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Connection {
|
||||
const Connection({
|
||||
required this.id,
|
||||
required this.clinicTenantId,
|
||||
required this.labTenantId,
|
||||
required this.status,
|
||||
this.clinicName,
|
||||
this.labName,
|
||||
this.dateCreated,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String clinicTenantId;
|
||||
final String labTenantId;
|
||||
final ConnectionStatus status;
|
||||
final String? clinicName;
|
||||
final String? labName;
|
||||
final String? dateCreated;
|
||||
|
||||
factory Connection.fromJson(Map<String, dynamic> j) {
|
||||
final expand = j['expand'] as Map<String, dynamic>?;
|
||||
final clinicExp = expand?['clinic_tenant_id'] as Map<String, dynamic>?;
|
||||
final labExp = expand?['lab_tenant_id'] as Map<String, dynamic>?;
|
||||
return Connection(
|
||||
id: j['id'] as String,
|
||||
clinicTenantId: j['clinic_tenant_id'] as String,
|
||||
labTenantId: j['lab_tenant_id'] as String,
|
||||
status: ConnectionStatus.values.firstWhere(
|
||||
(e) => e.value == j['status'],
|
||||
orElse: () => ConnectionStatus.pending,
|
||||
),
|
||||
clinicName: clinicExp?['company_name'] as String?,
|
||||
labName: labExp?['company_name'] as String?,
|
||||
dateCreated: j['created'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
enum FinanceType { receivable, payable }
|
||||
|
||||
extension FinanceTypeX on FinanceType {
|
||||
String get value => name;
|
||||
String get label => this == FinanceType.receivable ? 'Alacak' : 'Borç';
|
||||
}
|
||||
|
||||
enum FinanceStatus { pending, paid }
|
||||
|
||||
extension FinanceStatusX on FinanceStatus {
|
||||
String get value => name;
|
||||
String get label => this == FinanceStatus.pending ? 'Bekliyor' : 'Ödendi';
|
||||
}
|
||||
|
||||
class FinanceEntry {
|
||||
const FinanceEntry({
|
||||
required this.id,
|
||||
required this.tenantId,
|
||||
required this.jobId,
|
||||
required this.type,
|
||||
required this.amount,
|
||||
required this.currency,
|
||||
required this.status,
|
||||
this.paidAt,
|
||||
this.counterpartyName,
|
||||
this.patientCode,
|
||||
this.dateCreated,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String tenantId;
|
||||
final String jobId;
|
||||
final FinanceType type;
|
||||
final double amount;
|
||||
final String currency;
|
||||
final FinanceStatus status;
|
||||
final String? paidAt;
|
||||
final String? counterpartyName;
|
||||
final String? patientCode;
|
||||
final String? dateCreated;
|
||||
|
||||
factory FinanceEntry.fromJson(Map<String, dynamic> j) {
|
||||
final expand = j['expand'] as Map<String, dynamic>?;
|
||||
final jobExp = expand?['job_id'] as Map<String, dynamic>?;
|
||||
String? _str(dynamic v) { final s = v as String?; return (s == null || s.isEmpty) ? null : s; }
|
||||
return FinanceEntry(
|
||||
id: j['id'] as String,
|
||||
tenantId: j['tenant_id'] as String,
|
||||
jobId: j['job_id'] as String,
|
||||
type: FinanceType.values.firstWhere((e) => e.value == j['type'],
|
||||
orElse: () => FinanceType.receivable),
|
||||
amount: (j['amount'] as num).toDouble(),
|
||||
currency: j['currency'] as String? ?? 'TRY',
|
||||
status: FinanceStatus.values.firstWhere((e) => e.value == j['status'],
|
||||
orElse: () => FinanceStatus.pending),
|
||||
paidAt: _str(j['paid_at']),
|
||||
counterpartyName: _str(j['counterparty_name']),
|
||||
patientCode: jobExp?['patient_code'] as String?,
|
||||
dateCreated: j['created'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
enum JobStatus { pending, inProgress, sent, delivered, cancelled }
|
||||
|
||||
enum JobStep {
|
||||
olcu, // legacy fallback
|
||||
altYapiProva, // sabit seramik/metal — alt yapı (coping)
|
||||
ustYapiProva, // sabit seramik — bisküvi prova
|
||||
mumProva, // hareketli protez — mum prova
|
||||
dislerProva, // hareketli protez — dişler prova
|
||||
dayanakProva, // implant — dayanak prova
|
||||
kronProva, // implant — kron prova
|
||||
cilaBitim, // son cila / bitim (her şablonda son adım)
|
||||
}
|
||||
|
||||
enum JobLocation { atClinic, atLab }
|
||||
|
||||
enum ProstheticType {
|
||||
metalPorselen,
|
||||
zirkonyum,
|
||||
implantUstuZirkonyum,
|
||||
gecici,
|
||||
eMax,
|
||||
tamProtez,
|
||||
parsiyel,
|
||||
diger,
|
||||
}
|
||||
|
||||
// ── Status ────────────────────────────────────────────────────────────────────
|
||||
|
||||
extension JobStatusExt on JobStatus {
|
||||
String get label => switch (this) {
|
||||
JobStatus.pending => 'Bekliyor',
|
||||
JobStatus.inProgress => 'İşlemde',
|
||||
JobStatus.sent => 'Gönderildi',
|
||||
JobStatus.delivered => 'Teslim Alındı',
|
||||
JobStatus.cancelled => 'İptal',
|
||||
};
|
||||
String get value => switch (this) {
|
||||
JobStatus.pending => 'pending',
|
||||
JobStatus.inProgress => 'in_progress',
|
||||
JobStatus.sent => 'sent',
|
||||
JobStatus.delivered => 'delivered',
|
||||
JobStatus.cancelled => 'cancelled',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Step ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
extension JobStepExt on JobStep {
|
||||
String get label => switch (this) {
|
||||
JobStep.olcu => 'Ölçü',
|
||||
JobStep.altYapiProva => 'Alt Yapı Prova',
|
||||
JobStep.ustYapiProva => 'Üst Yapı Prova',
|
||||
JobStep.mumProva => 'Mum Prova',
|
||||
JobStep.dislerProva => 'Dişler Prova',
|
||||
JobStep.dayanakProva => 'Dayanak Prova',
|
||||
JobStep.kronProva => 'Kron Prova',
|
||||
JobStep.cilaBitim => 'Cila / Bitim',
|
||||
};
|
||||
|
||||
/// One-liner shown under the step on the stepper
|
||||
String get description => switch (this) {
|
||||
JobStep.olcu => 'İlk ölçü alındı',
|
||||
JobStep.altYapiProva => 'Metal/zirkonyum coping klinik onayı',
|
||||
JobStep.ustYapiProva => 'Bisküvi pişirimi sonrası klinik onayı',
|
||||
JobStep.mumProva => 'Mum prova klinik onayı',
|
||||
JobStep.dislerProva => 'Diş dizimi klinik onayı',
|
||||
JobStep.dayanakProva => 'Dayanak klinik onayı',
|
||||
JobStep.kronProva => 'Kron klinik onayı',
|
||||
JobStep.cilaBitim => 'Son cila ve teslim hazırlığı',
|
||||
};
|
||||
|
||||
String get value => switch (this) {
|
||||
JobStep.olcu => 'olcu',
|
||||
JobStep.altYapiProva => 'alt_yapi_prova',
|
||||
JobStep.ustYapiProva => 'ust_yapi_prova',
|
||||
JobStep.mumProva => 'mum_prova',
|
||||
JobStep.dislerProva => 'disler_prova',
|
||||
JobStep.dayanakProva => 'dayanak_prova',
|
||||
JobStep.kronProva => 'kron_prova',
|
||||
JobStep.cilaBitim => 'cila_bitim',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Prosthetic type ───────────────────────────────────────────────────────────
|
||||
|
||||
extension ProstheticTypeExt on ProstheticType {
|
||||
String get label => switch (this) {
|
||||
ProstheticType.metalPorselen => 'Metal Porselen',
|
||||
ProstheticType.zirkonyum => 'Zirkonyum',
|
||||
ProstheticType.implantUstuZirkonyum => 'İmplant Üstü Zirkonyum',
|
||||
ProstheticType.gecici => 'Geçici',
|
||||
ProstheticType.eMax => 'E-Max',
|
||||
ProstheticType.tamProtez => 'Tam Protez',
|
||||
ProstheticType.parsiyel => 'Parsiyel Protez',
|
||||
ProstheticType.diger => 'Diğer',
|
||||
};
|
||||
String get value => switch (this) {
|
||||
ProstheticType.metalPorselen => 'metal_porselen',
|
||||
ProstheticType.zirkonyum => 'zirkonyum',
|
||||
ProstheticType.implantUstuZirkonyum => 'implant_ustu_zirkonyum',
|
||||
ProstheticType.gecici => 'gecici',
|
||||
ProstheticType.eMax => 'e_max',
|
||||
ProstheticType.tamProtez => 'tam_protez',
|
||||
ProstheticType.parsiyel => 'parsiyel',
|
||||
ProstheticType.diger => 'diger',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Step template ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns the ordered step list for a given prosthetic type + prova flag.
|
||||
List<JobStep> jobStepTemplate(ProstheticType type, bool provaRequired) {
|
||||
if (!provaRequired) return const [JobStep.cilaBitim];
|
||||
return switch (type) {
|
||||
// Sabit seramik: alt yapı coping + bisküvi prova + cila
|
||||
ProstheticType.metalPorselen ||
|
||||
ProstheticType.zirkonyum ||
|
||||
ProstheticType.eMax =>
|
||||
const [JobStep.altYapiProva, JobStep.ustYapiProva, JobStep.cilaBitim],
|
||||
// İmplant: dayanak + kron prova + cila
|
||||
ProstheticType.implantUstuZirkonyum =>
|
||||
const [JobStep.dayanakProva, JobStep.kronProva, JobStep.cilaBitim],
|
||||
// Hareketli protez: mum + dişler prova + cila
|
||||
ProstheticType.tamProtez ||
|
||||
ProstheticType.parsiyel =>
|
||||
const [JobStep.mumProva, JobStep.dislerProva, JobStep.cilaBitim],
|
||||
// Geçici: sadece cila (prova gereksiz)
|
||||
ProstheticType.gecici =>
|
||||
const [JobStep.cilaBitim],
|
||||
// Diğer: tek ara prova + cila
|
||||
_ =>
|
||||
const [JobStep.altYapiProva, JobStep.cilaBitim],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Job ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
class Job {
|
||||
const Job({
|
||||
required this.id,
|
||||
required this.clinicTenantId,
|
||||
required this.labTenantId,
|
||||
required this.patientCode,
|
||||
required this.prostheticType,
|
||||
required this.memberCount,
|
||||
required this.status,
|
||||
required this.dateCreated,
|
||||
this.patientId,
|
||||
this.prostheticId,
|
||||
this.teeth = const [],
|
||||
this.color,
|
||||
this.description,
|
||||
this.price,
|
||||
this.currency,
|
||||
this.currentStep,
|
||||
this.location = JobLocation.atClinic,
|
||||
this.dueDate,
|
||||
this.clinicName,
|
||||
this.labName,
|
||||
this.attachments = const [],
|
||||
this.provaRequired = true,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String clinicTenantId;
|
||||
final String labTenantId;
|
||||
final String? patientId;
|
||||
final String patientCode;
|
||||
final String? prostheticId;
|
||||
final ProstheticType prostheticType;
|
||||
final int memberCount;
|
||||
final List<String> teeth;
|
||||
final String? color;
|
||||
final String? description;
|
||||
final double? price;
|
||||
final String? currency;
|
||||
final JobStatus status;
|
||||
final JobStep? currentStep;
|
||||
final JobLocation location;
|
||||
final DateTime? dueDate;
|
||||
final DateTime dateCreated;
|
||||
final List<String> attachments;
|
||||
final bool provaRequired;
|
||||
|
||||
// Denormalized from relation joins — list views only
|
||||
final String? clinicName;
|
||||
final String? labName;
|
||||
|
||||
// ── copyWith ──────────────────────────────────────────────────────────────
|
||||
|
||||
Job copyWith({
|
||||
JobStatus? status,
|
||||
JobStep? currentStep,
|
||||
JobLocation? location,
|
||||
String? clinicName,
|
||||
String? labName,
|
||||
bool clearCurrentStep = false,
|
||||
}) =>
|
||||
Job(
|
||||
id: id,
|
||||
clinicTenantId: clinicTenantId,
|
||||
labTenantId: labTenantId,
|
||||
patientId: patientId,
|
||||
patientCode: patientCode,
|
||||
prostheticId: prostheticId,
|
||||
prostheticType: prostheticType,
|
||||
memberCount: memberCount,
|
||||
teeth: teeth,
|
||||
color: color,
|
||||
description: description,
|
||||
price: price,
|
||||
currency: currency,
|
||||
status: status ?? this.status,
|
||||
currentStep: clearCurrentStep ? null : (currentStep ?? this.currentStep),
|
||||
location: location ?? this.location,
|
||||
dueDate: dueDate,
|
||||
dateCreated: dateCreated,
|
||||
attachments: attachments,
|
||||
provaRequired: provaRequired,
|
||||
clinicName: clinicName ?? this.clinicName,
|
||||
labName: labName ?? this.labName,
|
||||
);
|
||||
|
||||
// ── Step helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
List<JobStep> get stepTemplate => jobStepTemplate(prostheticType, provaRequired);
|
||||
|
||||
bool get isLastStep =>
|
||||
currentStep != null && currentStep == stepTemplate.last;
|
||||
|
||||
/// The next step after currentStep in this job's template, or null if done.
|
||||
JobStep? get nextStep {
|
||||
if (currentStep == null) return stepTemplate.firstOrNull;
|
||||
final idx = stepTemplate.indexOf(currentStep!);
|
||||
if (idx < 0 || idx >= stepTemplate.length - 1) return null;
|
||||
return stepTemplate[idx + 1];
|
||||
}
|
||||
|
||||
factory Job.fromJson(Map<String, dynamic> j) {
|
||||
final expand = j['expand'] as Map<String, dynamic>?;
|
||||
final clinicExp = expand?['clinic_tenant_id'] as Map<String, dynamic>?;
|
||||
final labExp = expand?['lab_tenant_id'] as Map<String, dynamic>?;
|
||||
String? str(dynamic v) {
|
||||
final s = v as String?;
|
||||
return (s == null || s.isEmpty) ? null : s;
|
||||
}
|
||||
|
||||
return Job(
|
||||
id: j['id'] as String,
|
||||
clinicTenantId: j['clinic_tenant_id'] as String,
|
||||
labTenantId: j['lab_tenant_id'] as String,
|
||||
patientId: str(j['patient_id']),
|
||||
patientCode: j['patient_code'] as String,
|
||||
prostheticId: str(j['prosthetic_id']),
|
||||
prostheticType: _parseProstheticType(j['prosthetic_type'] as String),
|
||||
memberCount: (j['member_count'] as num).toInt(),
|
||||
teeth: j['teeth'] is List
|
||||
? (j['teeth'] as List).map((e) => e.toString()).toList()
|
||||
: [],
|
||||
color: str(j['color']),
|
||||
description: str(j['description']),
|
||||
price: (j['price'] as num?)?.toDouble(),
|
||||
currency: str(j['currency']),
|
||||
status: _parseStatus(j['status'] as String),
|
||||
currentStep: str(j['current_step']) != null
|
||||
? _parseStep(j['current_step'] as String)
|
||||
: null,
|
||||
location:
|
||||
j['location'] == 'at_lab' ? JobLocation.atLab : JobLocation.atClinic,
|
||||
dueDate: str(j['due_date']) != null
|
||||
? DateTime.parse(j['due_date'] as String)
|
||||
: null,
|
||||
dateCreated: DateTime.parse(j['created'] as String),
|
||||
clinicName: clinicExp?['company_name'] as String?,
|
||||
labName: labExp?['company_name'] as String?,
|
||||
attachments: j['attachments'] is List
|
||||
? (j['attachments'] as List).map((e) => e.toString()).toList()
|
||||
: [],
|
||||
provaRequired: (j['prova_required'] as bool?) ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
static JobStatus _parseStatus(String s) => switch (s) {
|
||||
'in_progress' => JobStatus.inProgress,
|
||||
'sent' => JobStatus.sent,
|
||||
'delivered' => JobStatus.delivered,
|
||||
'cancelled' => JobStatus.cancelled,
|
||||
_ => JobStatus.pending,
|
||||
};
|
||||
|
||||
static JobStep _parseStep(String s) => switch (s) {
|
||||
'alt_yapi_prova' => JobStep.altYapiProva,
|
||||
'ust_yapi_prova' => JobStep.ustYapiProva,
|
||||
'mum_prova' => JobStep.mumProva,
|
||||
'disler_prova' => JobStep.dislerProva,
|
||||
'dayanak_prova' => JobStep.dayanakProva,
|
||||
'kron_prova' => JobStep.kronProva,
|
||||
'cila_bitim' => JobStep.cilaBitim,
|
||||
_ => JobStep.olcu,
|
||||
};
|
||||
|
||||
static ProstheticType _parseProstheticType(String s) => switch (s) {
|
||||
'zirkonyum' => ProstheticType.zirkonyum,
|
||||
'implant_ustu_zirkonyum'=> ProstheticType.implantUstuZirkonyum,
|
||||
'gecici' => ProstheticType.gecici,
|
||||
'e_max' => ProstheticType.eMax,
|
||||
'tam_protez' => ProstheticType.tamProtez,
|
||||
'parsiyel' => ProstheticType.parsiyel,
|
||||
'diger' => ProstheticType.diger,
|
||||
_ => ProstheticType.metalPorselen,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
enum JobFileKind { scan, image, document }
|
||||
|
||||
extension JobFileKindExt on JobFileKind {
|
||||
String get label => switch (this) {
|
||||
JobFileKind.scan => 'Tarama',
|
||||
JobFileKind.image => 'Görsel',
|
||||
JobFileKind.document => 'Belge',
|
||||
};
|
||||
String get value => switch (this) {
|
||||
JobFileKind.scan => 'scan',
|
||||
JobFileKind.image => 'image',
|
||||
JobFileKind.document => 'document',
|
||||
};
|
||||
|
||||
static JobFileKind fromValue(String s) => switch (s) {
|
||||
'image' => JobFileKind.image,
|
||||
'document' => JobFileKind.document,
|
||||
_ => JobFileKind.scan,
|
||||
};
|
||||
}
|
||||
|
||||
class JobFile {
|
||||
const JobFile({
|
||||
required this.id,
|
||||
required this.jobId,
|
||||
required this.clinicTenantId,
|
||||
required this.labTenantId,
|
||||
required this.uploadedBy,
|
||||
required this.kind,
|
||||
required this.fileName,
|
||||
required this.name,
|
||||
required this.size,
|
||||
required this.createdAt,
|
||||
required this.downloadUrl,
|
||||
this.mimeType,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String jobId;
|
||||
final String clinicTenantId;
|
||||
final String labTenantId;
|
||||
final String uploadedBy;
|
||||
final JobFileKind kind;
|
||||
final String fileName;
|
||||
final String name;
|
||||
final int size;
|
||||
final String? mimeType;
|
||||
final DateTime createdAt;
|
||||
final String downloadUrl;
|
||||
|
||||
String get sizeLabel {
|
||||
if (size < 1024) return '$size B';
|
||||
if (size < 1024 * 1024) return '${(size / 1024).toStringAsFixed(1)} KB';
|
||||
return '${(size / (1024 * 1024)).toStringAsFixed(2)} MB';
|
||||
}
|
||||
|
||||
factory JobFile.fromJson(Map<String, dynamic> j, String baseUrl) {
|
||||
String str(String key, [String fallback = '']) =>
|
||||
(j[key] as String?) ?? fallback;
|
||||
final id = str('id');
|
||||
final collectionId = str('collectionId', 'job_files');
|
||||
final fileName = str('file');
|
||||
final url = fileName.isNotEmpty
|
||||
? '$baseUrl/api/files/$collectionId/$id/$fileName'
|
||||
: '';
|
||||
final createdRaw = str('created');
|
||||
return JobFile(
|
||||
id: id,
|
||||
jobId: str('job_id'),
|
||||
clinicTenantId: str('clinic_tenant_id'),
|
||||
labTenantId: str('lab_tenant_id'),
|
||||
uploadedBy: str('uploaded_by'),
|
||||
kind: JobFileKindExt.fromValue(str('kind')),
|
||||
fileName: fileName,
|
||||
name: str('name'),
|
||||
size: (j['size'] as num?)?.toInt() ?? 0,
|
||||
mimeType: j['mime_type'] as String?,
|
||||
createdAt: createdRaw.isNotEmpty
|
||||
? DateTime.tryParse(createdRaw) ?? DateTime(2000)
|
||||
: DateTime(2000),
|
||||
downloadUrl: url,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
class Patient {
|
||||
const Patient({
|
||||
required this.id,
|
||||
required this.tenantId,
|
||||
required this.patientCode,
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
this.birthDate,
|
||||
this.phone,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String tenantId;
|
||||
final String patientCode;
|
||||
final String? firstName;
|
||||
final String? lastName;
|
||||
final String? birthDate;
|
||||
final String? phone;
|
||||
final String? notes;
|
||||
|
||||
String get displayName {
|
||||
final parts = [firstName, lastName].where((s) => s != null && s.isNotEmpty);
|
||||
return parts.isEmpty ? patientCode : parts.join(' ');
|
||||
}
|
||||
|
||||
factory Patient.fromJson(Map<String, dynamic> j) => Patient(
|
||||
id: j['id'] as String,
|
||||
tenantId: j['tenant_id'] is Map
|
||||
? (j['tenant_id'] as Map)['id'] as String
|
||||
: j['tenant_id'] as String,
|
||||
patientCode: j['patient_code'] as String,
|
||||
firstName: j['first_name'] as String?,
|
||||
lastName: j['last_name'] as String?,
|
||||
birthDate: j['birth_date'] as String?,
|
||||
phone: j['phone'] as String?,
|
||||
notes: j['notes'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'tenant_id': tenantId,
|
||||
'patient_code': patientCode,
|
||||
if (firstName != null) 'first_name': firstName,
|
||||
if (lastName != null) 'last_name': lastName,
|
||||
if (birthDate != null) 'birth_date': birthDate,
|
||||
if (phone != null) 'phone': phone,
|
||||
if (notes != null) 'notes': notes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
class ProstheticProduct {
|
||||
const ProstheticProduct({
|
||||
required this.id,
|
||||
required this.labTenantId,
|
||||
required this.name,
|
||||
required this.prostheticType,
|
||||
this.unitPrice,
|
||||
this.currency,
|
||||
this.isActive = true,
|
||||
this.description,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String labTenantId;
|
||||
final String name;
|
||||
final String prostheticType;
|
||||
final double? unitPrice;
|
||||
final String? currency;
|
||||
final bool isActive;
|
||||
final String? description;
|
||||
|
||||
factory ProstheticProduct.fromJson(Map<String, dynamic> j) {
|
||||
String? _str(dynamic v) { final s = v as String?; return (s == null || s.isEmpty) ? null : s; }
|
||||
return ProstheticProduct(
|
||||
id: j['id'] as String,
|
||||
labTenantId: j['lab_tenant_id'] as String,
|
||||
name: j['name'] as String,
|
||||
prostheticType: j['prosthetic_type'] as String,
|
||||
unitPrice: (j['unit_price'] as num?)?.toDouble(),
|
||||
currency: j['currency'] as String? ?? 'TRY',
|
||||
isActive: j['is_active'] as bool? ?? true,
|
||||
description: _str(j['description']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'lab_tenant_id': labTenantId,
|
||||
'name': name,
|
||||
'prosthetic_type': prostheticType,
|
||||
if (unitPrice != null) 'unit_price': unitPrice,
|
||||
'currency': currency ?? 'TRY',
|
||||
'is_active': isActive,
|
||||
if (description != null) 'description': description,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
enum TenantKind { lab, clinic }
|
||||
|
||||
enum TenantRole {
|
||||
owner,
|
||||
admin,
|
||||
technician, // lab: işler + ürünler
|
||||
delivery, // lab: işler
|
||||
finance, // lab+clinic: finans
|
||||
doctor, // clinic: işler + hastalar
|
||||
member, // legacy — full access
|
||||
;
|
||||
|
||||
String get value => name;
|
||||
|
||||
String get label => switch (this) {
|
||||
TenantRole.owner => 'Sahibi',
|
||||
TenantRole.admin => 'Yönetici',
|
||||
TenantRole.technician => 'Teknisyen',
|
||||
TenantRole.delivery => 'Teslimat Elemanı',
|
||||
TenantRole.finance => 'Finans Elemanı',
|
||||
TenantRole.doctor => 'Hekim',
|
||||
TenantRole.member => 'Üye',
|
||||
};
|
||||
}
|
||||
|
||||
enum TenantPlan { starter, pro, enterprise }
|
||||
|
||||
class Tenant {
|
||||
const Tenant({
|
||||
required this.id,
|
||||
required this.kind,
|
||||
required this.memberNumber,
|
||||
required this.companyName,
|
||||
this.logo,
|
||||
this.defaultCurrency = 'TRY',
|
||||
this.status = 'active',
|
||||
this.plan,
|
||||
this.maxMembers,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final TenantKind kind;
|
||||
final String memberNumber;
|
||||
final String companyName;
|
||||
final String? logo;
|
||||
final String defaultCurrency;
|
||||
final String status;
|
||||
final TenantPlan? plan;
|
||||
final int? maxMembers;
|
||||
|
||||
bool get isLab => kind == TenantKind.lab;
|
||||
bool get isClinic => kind == TenantKind.clinic;
|
||||
|
||||
factory Tenant.fromJson(Map<String, dynamic> j) => Tenant(
|
||||
id: j['id'] as String,
|
||||
kind: j['kind'] == 'lab' ? TenantKind.lab : TenantKind.clinic,
|
||||
memberNumber: (j['member_number'] as String?) ?? '',
|
||||
companyName: j['company_name'] as String,
|
||||
logo: j['logo'] as String?,
|
||||
defaultCurrency: (j['default_currency'] as String?) ?? 'TRY',
|
||||
status: (j['status'] as String?) ?? 'active',
|
||||
plan: _parsePlan(j['plan'] as String?),
|
||||
maxMembers: (j['max_members'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
static TenantPlan? _parsePlan(String? p) => switch (p) {
|
||||
'starter' => TenantPlan.starter,
|
||||
'pro' => TenantPlan.pro,
|
||||
'enterprise' => TenantPlan.enterprise,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
class TenantMembership {
|
||||
const TenantMembership({
|
||||
required this.id,
|
||||
required this.tenant,
|
||||
required this.role,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final Tenant tenant;
|
||||
final TenantRole role;
|
||||
|
||||
// ── Access helpers ────────────────────────────────────────────────────────
|
||||
bool get isOwner => role == TenantRole.owner;
|
||||
bool get isAdmin => role == TenantRole.admin || role == TenantRole.owner;
|
||||
bool get canManageUsers => role == TenantRole.owner || role == TenantRole.admin;
|
||||
bool get canManageJobs => role != TenantRole.finance;
|
||||
bool get canManageFinance => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.finance || role == TenantRole.member;
|
||||
bool get canManageProducts => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.technician || role == TenantRole.member;
|
||||
bool get canViewPatients => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.doctor || role == TenantRole.member;
|
||||
bool get canManageConnections => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.member;
|
||||
|
||||
// ── Fine-grained job actions ──────────────────────────────────────────────
|
||||
/// Can create new jobs (clinic side: owner/admin/doctor/member; not delivery/finance)
|
||||
bool get canCreateJobs => role != TenantRole.delivery && role != TenantRole.finance;
|
||||
|
||||
/// Can confirm physical delivery (delivery role + supervisors)
|
||||
bool get canDeliverJobs => role != TenantRole.finance;
|
||||
|
||||
/// Can cancel or fully manage job lifecycle (not delivery-only or finance)
|
||||
bool get canCancelJobs => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.member || role == TenantRole.doctor;
|
||||
|
||||
/// Primary focus is delivery — restrict to delivery-relevant UI
|
||||
bool get isDeliveryOnly => role == TenantRole.delivery;
|
||||
|
||||
// ── Nav visibility ────────────────────────────────────────────────────────
|
||||
bool get showDashboard => true;
|
||||
bool get showJobs => canManageJobs;
|
||||
bool get showProducts => tenant.isLab && canManageProducts;
|
||||
bool get showPatients => tenant.isClinic && canViewPatients;
|
||||
bool get showConnections => canManageConnections;
|
||||
bool get showFinance => canManageFinance;
|
||||
|
||||
factory TenantMembership.fromJson(Map<String, dynamic> j) {
|
||||
final expand = j['expand'] as Map<String, dynamic>?;
|
||||
final tenantData = expand?['tenant_id'] as Map<String, dynamic>?;
|
||||
return TenantMembership(
|
||||
id: j['id'] as String,
|
||||
tenant: Tenant.fromJson(tenantData!),
|
||||
role: parseRole(j['role'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
static TenantRole parseRole(String r) => switch (r) {
|
||||
'owner' => TenantRole.owner,
|
||||
'admin' => TenantRole.admin,
|
||||
'technician' => TenantRole.technician,
|
||||
'delivery' => TenantRole.delivery,
|
||||
'finance' => TenantRole.finance,
|
||||
'doctor' => TenantRole.doctor,
|
||||
_ => TenantRole.member,
|
||||
};
|
||||
|
||||
String get roleLabel => switch (role) {
|
||||
TenantRole.owner => 'Sahibi',
|
||||
TenantRole.admin => 'Yönetici',
|
||||
TenantRole.technician => 'Teknisyen',
|
||||
TenantRole.delivery => 'Teslimat Elemanı',
|
||||
TenantRole.finance => 'Finans Elemanı',
|
||||
TenantRole.doctor => 'Hekim',
|
||||
TenantRole.member => 'Üye',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'tenant.dart';
|
||||
|
||||
class TenantInvite {
|
||||
const TenantInvite({
|
||||
required this.id,
|
||||
required this.tenantId,
|
||||
required this.email,
|
||||
required this.jobRole,
|
||||
required this.token,
|
||||
required this.expiresAt,
|
||||
required this.status,
|
||||
required this.invitedById,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String tenantId;
|
||||
final String email;
|
||||
final TenantRole jobRole;
|
||||
final String token;
|
||||
final DateTime expiresAt;
|
||||
final String status; // pending | accepted | expired
|
||||
final String invitedById;
|
||||
|
||||
bool get isPending => status == 'pending';
|
||||
bool get isExpired => status == 'expired' || expiresAt.isBefore(DateTime.now());
|
||||
|
||||
factory TenantInvite.fromJson(Map<String, dynamic> j) => TenantInvite(
|
||||
id: j['id'] as String,
|
||||
tenantId: j['tenant_id'] as String,
|
||||
email: j['email'] as String,
|
||||
jobRole: TenantMembership.parseRole(j['job_role'] as String),
|
||||
token: j['token'] as String,
|
||||
expiresAt: DateTime.parse(j['expires_at'] as String),
|
||||
status: j['status'] as String,
|
||||
invitedById: j['invited_by'] as String,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
class UserProfile {
|
||||
const UserProfile({
|
||||
required this.id,
|
||||
required this.email,
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
this.preferredLanguage,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String email;
|
||||
final String? firstName;
|
||||
final String? lastName;
|
||||
final String? preferredLanguage;
|
||||
|
||||
String get displayName =>
|
||||
[firstName, lastName].where((s) => s != null && s.isNotEmpty).join(' ');
|
||||
|
||||
factory UserProfile.fromJson(Map<String, dynamic> j) => UserProfile(
|
||||
id: j['id'] as String,
|
||||
email: j['email'] as String,
|
||||
firstName: _str(j['first_name']),
|
||||
lastName: _str(j['last_name']),
|
||||
preferredLanguage: _str(j['preferred_language']),
|
||||
);
|
||||
|
||||
static String? _str(dynamic v) {
|
||||
final s = v as String?;
|
||||
return (s == null || s.isEmpty) ? null : s;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user