Add pricing entry flow and platform admin foundations

This commit is contained in:
egecankomur
2026-06-20 18:24:40 +03:00
parent 1d36ccdf30
commit ac42681f7e
44 changed files with 6567 additions and 1419 deletions
+46 -6
View File
@@ -1,5 +1,6 @@
import 'package:pocketbase/pocketbase.dart';
import '../api/pocketbase_client.dart';
import '../../models/platform_admin.dart';
import '../../models/tenant.dart';
import '../../models/user_profile.dart';
@@ -73,10 +74,24 @@ class AuthRepository {
String id, {
String? companyName,
String? defaultCurrency,
String? companyAddress,
String? city,
String? district,
double? latitude,
double? longitude,
List<String>? workflowOverrides,
}) async {
final body = <String, dynamic>{};
if (companyName != null) body['company_name'] = companyName;
if (defaultCurrency != null) body['default_currency'] = defaultCurrency;
if (companyAddress != null) body['company_address'] = companyAddress;
if (city != null) body['city'] = city;
if (district != null) body['district'] = district;
if (latitude != null) body['latitude'] = latitude;
if (longitude != null) body['longitude'] = longitude;
if (workflowOverrides != null) {
body['workflow_overrides'] = workflowOverrides;
}
if (body.isEmpty) return;
await _pb.collection('tenants').update(id, body: body);
}
@@ -85,26 +100,51 @@ class AuthRepository {
final record = _pb.authStore.record!;
final user = UserProfile.fromJson(record.toJson());
List<TenantMembership> tenants = [];
List<PlatformMembership> platformMemberships = [];
try {
tenants = await _fetchUserTenants(record.id);
} catch (_) {}
return AuthResult(user: user, tenants: tenants);
try {
platformMemberships = await _fetchPlatformMemberships(record.id);
} catch (_) {}
return AuthResult(
user: user,
tenants: tenants,
platformMemberships: platformMemberships,
);
}
Future<List<TenantMembership>> _fetchUserTenants(String userId) async {
final result = await _pb.collection('tenant_members').getList(
filter: 'user_id = "$userId"',
expand: 'tenant_id',
perPage: 50,
);
filter: 'user_id = "$userId"',
expand: 'tenant_id',
perPage: 50,
);
return result.items
.map((r) => TenantMembership.fromJson(r.toJson()))
.toList();
}
Future<List<PlatformMembership>> _fetchPlatformMemberships(
String userId,
) async {
final result = await _pb.collection('platform_memberships').getList(
filter: 'user_id = "$userId"',
perPage: 20,
);
return result.items
.map((r) => PlatformMembership.fromJson(r.toJson()))
.toList();
}
}
class AuthResult {
const AuthResult({required this.user, required this.tenants});
const AuthResult({
required this.user,
required this.tenants,
this.platformMemberships = const [],
});
final UserProfile user;
final List<TenantMembership> tenants;
final List<PlatformMembership> platformMemberships;
}
@@ -0,0 +1,30 @@
import 'package:geolocator/geolocator.dart';
class LocationAccessService {
LocationAccessService._();
static Future<Position> getCurrentPosition() async {
final enabled = await Geolocator.isLocationServiceEnabled();
if (!enabled) {
throw Exception(
'Konum servisleri kapalı. Lütfen cihaz ayarlarından açın.');
}
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.denied) {
throw Exception('Konum izni verilmedi.');
}
if (permission == LocationPermission.deniedForever) {
throw Exception(
'Konum izni kalıcı olarak reddedildi. Lütfen cihaz ayarlarından izin verin.',
);
}
return Geolocator.getCurrentPosition();
}
}
+12
View File
@@ -0,0 +1,12 @@
class OpenFreeMap {
OpenFreeMap._();
static const libertyStyle = 'https://tiles.openfreemap.org/styles/liberty';
static const positronStyle = 'https://tiles.openfreemap.org/styles/positron';
static const brightStyle = 'https://tiles.openfreemap.org/styles/bright';
static const darkStyle = 'https://tiles.openfreemap.org/styles/dark';
static const fiordStyle = 'https://tiles.openfreemap.org/styles/fiord';
static const attribution =
'OpenFreeMap © OpenMapTiles Data from OpenStreetMap';
}
+33 -10
View File
@@ -3,8 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pocketbase/pocketbase.dart';
import '../auth/auth_repository.dart';
import '../services/notification_service.dart';
import '../../models/platform_admin.dart';
import '../../models/tenant.dart';
import '../../models/user_profile.dart';
import '../../features/shared/tenant_location_data.dart';
import 'locale_provider.dart';
class AuthState {
@@ -12,6 +14,7 @@ class AuthState {
this.profile,
this.activeTenant,
this.memberships = const [],
this.platformMemberships = const [],
this.isLoading = true,
this.error,
});
@@ -19,15 +22,24 @@ class AuthState {
final UserProfile? profile;
final TenantMembership? activeTenant;
final List<TenantMembership> memberships;
final List<PlatformMembership> platformMemberships;
final bool isLoading;
final String? error;
bool get isAuthenticated => profile != null;
bool get isSuperAdmin => platformMemberships.any((m) => m.isSuperAdmin);
PlatformMembership? get primaryPlatformMembership {
for (final membership in platformMemberships) {
if (membership.isActive) return membership;
}
return null;
}
AuthState copyWith({
UserProfile? profile,
TenantMembership? activeTenant,
List<TenantMembership>? memberships,
List<PlatformMembership>? platformMemberships,
bool? isLoading,
String? error,
bool clearError = false,
@@ -36,6 +48,7 @@ class AuthState {
profile: profile ?? this.profile,
activeTenant: activeTenant ?? this.activeTenant,
memberships: memberships ?? this.memberships,
platformMemberships: platformMemberships ?? this.platformMemberships,
isLoading: isLoading ?? this.isLoading,
error: clearError ? null : (error ?? this.error),
);
@@ -60,11 +73,12 @@ class AuthNotifier extends StateNotifier<AuthState> {
state = AuthState(
profile: result.user,
memberships: result.tenants,
activeTenant:
result.tenants.isEmpty ? null : result.tenants.first,
platformMemberships: result.platformMemberships,
activeTenant: result.tenants.isEmpty ? null : result.tenants.first,
isLoading: false,
);
final isLab = result.tenants.isNotEmpty && result.tenants.first.tenant.isLab;
final isLab =
result.tenants.isNotEmpty && result.tenants.first.tenant.isLab;
NotificationService.loginUser(result.user.id, isLab: isLab);
_applyLocale(result.user.preferredLanguage);
} catch (_) {
@@ -93,11 +107,12 @@ class AuthNotifier extends StateNotifier<AuthState> {
state = AuthState(
profile: result.user,
memberships: result.tenants,
activeTenant:
result.tenants.isEmpty ? null : result.tenants.first,
platformMemberships: result.platformMemberships,
activeTenant: result.tenants.isEmpty ? null : result.tenants.first,
isLoading: false,
);
final isLab = result.tenants.isNotEmpty && result.tenants.first.tenant.isLab;
final isLab =
result.tenants.isNotEmpty && result.tenants.first.tenant.isLab;
NotificationService.loginUser(result.user.id, isLab: isLab);
_applyLocale(result.user.preferredLanguage);
} catch (e) {
@@ -122,8 +137,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
state = AuthState(
profile: result.user,
memberships: result.tenants,
activeTenant:
result.tenants.isEmpty ? null : result.tenants.first,
platformMemberships: result.platformMemberships,
activeTenant: result.tenants.isEmpty ? null : result.tenants.first,
isLoading: false,
);
} catch (e) {
@@ -157,6 +172,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
state = state.copyWith(
profile: result.user,
memberships: result.tenants,
platformMemberships: result.platformMemberships,
activeTenant: newActive,
);
} catch (_) {}
@@ -172,11 +188,19 @@ class AuthNotifier extends StateNotifier<AuthState> {
required String tenantId,
required String companyName,
String? defaultCurrency,
TenantLocationData? location,
List<String>? workflowOverrides,
}) async {
await _repo.updateTenant(
tenantId,
companyName: companyName,
defaultCurrency: defaultCurrency,
companyAddress: location?.address,
city: location?.city,
district: location?.district,
latitude: location?.latitude,
longitude: location?.longitude,
workflowOverrides: workflowOverrides,
);
await refresh();
}
@@ -194,8 +218,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
}
}
final authProvider =
StateNotifierProvider<AuthNotifier, AuthState>((ref) {
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier(
onLocaleLoaded: (code) =>
ref.read(localeProvider.notifier).setLocale(Locale(code)),
+282 -75
View File
@@ -10,6 +10,7 @@ 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/auth/welcome_pricing_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';
@@ -37,6 +38,7 @@ import '../../models/connection.dart';
const routeSignIn = '/sign-in';
const routeSignUp = '/sign-up';
const routeOnboarding = '/onboarding';
const routeWelcome = '/welcome';
// Clinic routes
const routeClinicDashboard = '/clinic/dashboard';
@@ -65,15 +67,20 @@ const routeLabAi = '/lab/ai';
const routeLabDiscounts = '/lab/discounts';
List<RouteBase> buildRoutes() => [
GoRoute(
path: routeWelcome, builder: (_, __) => const WelcomePricingScreen()),
GoRoute(path: routeSignIn, builder: (_, __) => const SignInScreen()),
GoRoute(path: routeSignUp, builder: (_, __) => const SignUpScreen()),
GoRoute(path: routeOnboarding, builder: (_, __) => const OnboardingScreen()),
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: routeClinicDashboard,
builder: (_, __) => const ClinicDashboardScreen()),
GoRoute(
path: routeClinicJobs,
builder: (_, __) => const ClinicJobsScreen(),
@@ -81,7 +88,8 @@ List<RouteBase> buildRoutes() => [
GoRoute(path: 'new', builder: (_, __) => const NewJobScreen()),
GoRoute(
path: ':jobId',
builder: (_, s) => ClinicJobDetailScreen(jobId: s.pathParameters['jobId']!),
builder: (_, s) =>
ClinicJobDetailScreen(jobId: s.pathParameters['jobId']!),
),
],
),
@@ -91,15 +99,25 @@ List<RouteBase> buildRoutes() => [
routes: [
GoRoute(
path: ':patientId',
builder: (_, s) => ClinicPatientDetailScreen(patientId: s.pathParameters['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()),
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()),
],
),
@@ -107,19 +125,26 @@ List<RouteBase> buildRoutes() => [
ShellRoute(
builder: (context, state, child) => _LabShell(child: child),
routes: [
GoRoute(path: routeLabDashboard, builder: (_, __) => const LabDashboardScreen()),
GoRoute(path: routeLabJobsInbound, builder: (_, __) => const LabJobsInboundScreen()),
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']!),
builder: (_, s) =>
LabJobDetailScreen(jobId: s.pathParameters['jobId']!),
),
],
),
GoRoute(path: routeLabProducts, builder: (_, __) => const LabProductsScreen()),
GoRoute(
path: routeLabProducts,
builder: (_, __) => const LabProductsScreen()),
GoRoute(
path: routeLabConnections,
builder: (_, __) => const LabConnectionsScreen(),
@@ -141,10 +166,17 @@ List<RouteBase> buildRoutes() => [
),
],
),
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: 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()),
],
),
@@ -216,11 +248,36 @@ class _ClinicShellState extends ConsumerState<_ClinicShell> {
String _selectedRoute = routeClinicDashboard;
List<_NavItem> _clinicTopSingles(AppStrings s) => [
_NavItem(route: routeClinicDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: s.homeTitle, visible: (_) => true),
_NavItem(route: routeClinicJobs, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: s.jobsTitle, visible: (m) => m?.showJobs ?? true),
_NavItem(route: routeClinicPatients, icon: const Icon(Icons.people_outline_rounded), selectedIcon: const Icon(Icons.people_rounded), label: s.patientsTitle, visible: (m) => m?.showPatients ?? true),
_NavItem(route: routeClinicFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: s.finance, visible: (m) => m?.showFinance ?? true),
_NavItem(route: routeClinicAi, icon: const Icon(Icons.auto_awesome_outlined), selectedIcon: const Icon(Icons.auto_awesome_rounded), label: s.aiAssistant, visible: (_) => true),
_NavItem(
route: routeClinicDashboard,
icon: const Icon(Icons.home_outlined),
selectedIcon: const Icon(Icons.home_rounded),
label: s.homeTitle,
visible: (_) => true),
_NavItem(
route: routeClinicJobs,
icon: const Icon(Icons.work_outline_rounded),
selectedIcon: const Icon(Icons.work_rounded),
label: s.jobsTitle,
visible: (m) => m?.showJobs ?? true),
_NavItem(
route: routeClinicPatients,
icon: const Icon(Icons.people_outline_rounded),
selectedIcon: const Icon(Icons.people_rounded),
label: s.patientsTitle,
visible: (m) => m?.showPatients ?? true),
_NavItem(
route: routeClinicFinance,
icon: const Icon(Icons.account_balance_outlined),
selectedIcon: const Icon(Icons.account_balance_rounded),
label: s.finance,
visible: (m) => m?.showFinance ?? true),
_NavItem(
route: routeClinicAi,
icon: const Icon(Icons.auto_awesome_outlined),
selectedIcon: const Icon(Icons.auto_awesome_rounded),
label: s.aiAssistant,
visible: (_) => true),
];
List<_NavGroup> _clinicGroups(AppStrings s) => [
@@ -229,22 +286,62 @@ class _ClinicShellState extends ConsumerState<_ClinicShell> {
icon: Icons.tune_rounded,
selectedIcon: Icons.tune_rounded,
items: [
_NavItem(route: routeClinicConnections, icon: const Icon(Icons.link_rounded), selectedIcon: const Icon(Icons.link_rounded), label: s.connections, visible: (_) => true),
_NavItem(route: routeClinicReports, icon: const Icon(Icons.bar_chart_outlined), selectedIcon: const Icon(Icons.bar_chart_rounded), label: s.reports, visible: (_) => true),
_NavItem(
route: routeClinicConnections,
icon: const Icon(Icons.link_rounded),
selectedIcon: const Icon(Icons.link_rounded),
label: s.connections,
visible: (_) => true),
_NavItem(
route: routeClinicReports,
icon: const Icon(Icons.bar_chart_outlined),
selectedIcon: const Icon(Icons.bar_chart_rounded),
label: s.reports,
visible: (_) => true),
],
),
];
List<_NavItem> _clinicBottomSingles(AppStrings s) => [
_NavItem(route: routeClinicSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: s.settings, visible: (_) => true),
_NavItem(
route: routeClinicSettings,
icon: const Icon(Icons.settings_outlined),
selectedIcon: const Icon(Icons.settings_rounded),
label: s.settings,
visible: (_) => true),
];
List<_NavItem> _clinicMobileItems(AppStrings s) => [
_NavItem(route: routeClinicDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: s.homeTitle, visible: (_) => true),
_NavItem(route: routeClinicJobs, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: s.jobsTitle, visible: (m) => m?.showJobs ?? true),
_NavItem(route: routeClinicPatients, icon: const Icon(Icons.people_outline_rounded), selectedIcon: const Icon(Icons.people_rounded), label: s.patientsTitle, visible: (m) => m?.showPatients ?? true),
_NavItem(route: routeClinicFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: s.finance, visible: (m) => m?.showFinance ?? true),
_NavItem(route: routeClinicSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: s.settings, visible: (_) => true),
_NavItem(
route: routeClinicDashboard,
icon: const Icon(Icons.home_outlined),
selectedIcon: const Icon(Icons.home_rounded),
label: s.homeTitle,
visible: (_) => true),
_NavItem(
route: routeClinicJobs,
icon: const Icon(Icons.work_outline_rounded),
selectedIcon: const Icon(Icons.work_rounded),
label: s.jobsTitle,
visible: (m) => m?.showJobs ?? true),
_NavItem(
route: routeClinicPatients,
icon: const Icon(Icons.people_outline_rounded),
selectedIcon: const Icon(Icons.people_rounded),
label: s.patientsTitle,
visible: (m) => m?.showPatients ?? true),
_NavItem(
route: routeClinicFinance,
icon: const Icon(Icons.account_balance_outlined),
selectedIcon: const Icon(Icons.account_balance_rounded),
label: s.finance,
visible: (m) => m?.showFinance ?? true),
_NavItem(
route: routeClinicSettings,
icon: const Icon(Icons.settings_outlined),
selectedIcon: const Icon(Icons.settings_rounded),
label: s.settings,
visible: (_) => true),
];
List<_SidebarEntry> _allEntries(AppStrings s) {
@@ -265,7 +362,8 @@ class _ClinicShellState extends ConsumerState<_ClinicShell> {
@override
Widget build(BuildContext context) {
final s = ref.watch(stringsProvider);
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
final isDesktop =
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
if (isDesktop) {
final entries = _allEntries(s);
@@ -289,7 +387,8 @@ class _ClinicShellState extends ConsumerState<_ClinicShell> {
// Mobile: only core items in bottom nav
final membership = ref.read(authProvider).activeTenant;
final items = _clinicMobileItems(s).where((it) => it.visible(membership)).toList();
final items =
_clinicMobileItems(s).where((it) => it.visible(membership)).toList();
final flatIndex = items.indexWhere((it) => it.route == _selectedRoute);
final clampedIndex = flatIndex >= 0 ? flatIndex : 0;
@@ -317,7 +416,10 @@ class _ClinicShellState extends ConsumerState<_ClinicShell> {
Semantics(
label: it.label,
button: true,
child: NavigationDestination(icon: it.icon, selectedIcon: it.selectedIcon, label: it.label),
child: NavigationDestination(
icon: it.icon,
selectedIcon: it.selectedIcon,
label: it.label),
),
],
),
@@ -339,11 +441,36 @@ class _LabShellState extends ConsumerState<_LabShell> {
String _selectedRoute = routeLabDashboard;
List<_NavItem> _labTopSingles(AppStrings s) => [
_NavItem(route: routeLabDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: s.homeTitle, visible: (_) => true),
_NavItem(route: routeLabJobsAll, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: s.jobsTitle, visible: (m) => m?.showJobs ?? true),
_NavItem(route: routeLabProducts, icon: const Icon(Icons.inventory_2_outlined), selectedIcon: const Icon(Icons.inventory_2_rounded), label: s.productsTitle, visible: (m) => m?.showProducts ?? true),
_NavItem(route: routeLabFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: s.finance, visible: (m) => m?.showFinance ?? true),
_NavItem(route: routeLabAi, icon: const Icon(Icons.auto_awesome_outlined), selectedIcon: const Icon(Icons.auto_awesome_rounded), label: s.aiAssistant, visible: (_) => true),
_NavItem(
route: routeLabDashboard,
icon: const Icon(Icons.home_outlined),
selectedIcon: const Icon(Icons.home_rounded),
label: s.homeTitle,
visible: (_) => true),
_NavItem(
route: routeLabJobsAll,
icon: const Icon(Icons.work_outline_rounded),
selectedIcon: const Icon(Icons.work_rounded),
label: s.jobsTitle,
visible: (m) => m?.showJobs ?? true),
_NavItem(
route: routeLabProducts,
icon: const Icon(Icons.inventory_2_outlined),
selectedIcon: const Icon(Icons.inventory_2_rounded),
label: s.productsTitle,
visible: (m) => m?.showProducts ?? true),
_NavItem(
route: routeLabFinance,
icon: const Icon(Icons.account_balance_outlined),
selectedIcon: const Icon(Icons.account_balance_rounded),
label: s.finance,
visible: (m) => m?.showFinance ?? true),
_NavItem(
route: routeLabAi,
icon: const Icon(Icons.auto_awesome_outlined),
selectedIcon: const Icon(Icons.auto_awesome_rounded),
label: s.aiAssistant,
visible: (_) => true),
];
List<_NavGroup> _labGroups(AppStrings s) => [
@@ -352,23 +479,68 @@ class _LabShellState extends ConsumerState<_LabShell> {
icon: Icons.tune_rounded,
selectedIcon: Icons.tune_rounded,
items: [
_NavItem(route: routeLabConnections, icon: const Icon(Icons.link_rounded), selectedIcon: const Icon(Icons.link_rounded), label: s.connections, visible: (_) => true),
_NavItem(route: routeLabDiscounts, icon: const Icon(Icons.local_offer_outlined), selectedIcon: const Icon(Icons.local_offer_rounded), label: s.discounts, visible: (_) => true),
_NavItem(route: routeLabReports, icon: const Icon(Icons.bar_chart_outlined), selectedIcon: const Icon(Icons.bar_chart_rounded), label: s.reports, visible: (_) => true),
_NavItem(
route: routeLabConnections,
icon: const Icon(Icons.link_rounded),
selectedIcon: const Icon(Icons.link_rounded),
label: s.connections,
visible: (_) => true),
_NavItem(
route: routeLabDiscounts,
icon: const Icon(Icons.local_offer_outlined),
selectedIcon: const Icon(Icons.local_offer_rounded),
label: s.discounts,
visible: (_) => true),
_NavItem(
route: routeLabReports,
icon: const Icon(Icons.bar_chart_outlined),
selectedIcon: const Icon(Icons.bar_chart_rounded),
label: s.reports,
visible: (_) => true),
],
),
];
List<_NavItem> _labBottomSingles(AppStrings s) => [
_NavItem(route: routeLabSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: s.settings, visible: (_) => true),
_NavItem(
route: routeLabSettings,
icon: const Icon(Icons.settings_outlined),
selectedIcon: const Icon(Icons.settings_rounded),
label: s.settings,
visible: (_) => true),
];
List<_NavItem> _labMobileItems(AppStrings s) => [
_NavItem(route: routeLabDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: s.homeTitle, visible: (_) => true),
_NavItem(route: routeLabJobsAll, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: s.jobsTitle, visible: (m) => m?.showJobs ?? true),
_NavItem(route: routeLabProducts, icon: const Icon(Icons.inventory_2_outlined), selectedIcon: const Icon(Icons.inventory_2_rounded), label: s.productsTitle, visible: (m) => m?.showProducts ?? true),
_NavItem(route: routeLabFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: s.finance, visible: (m) => m?.showFinance ?? true),
_NavItem(route: routeLabSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: s.settings, visible: (_) => true),
_NavItem(
route: routeLabDashboard,
icon: const Icon(Icons.home_outlined),
selectedIcon: const Icon(Icons.home_rounded),
label: s.homeTitle,
visible: (_) => true),
_NavItem(
route: routeLabJobsAll,
icon: const Icon(Icons.work_outline_rounded),
selectedIcon: const Icon(Icons.work_rounded),
label: s.jobsTitle,
visible: (m) => m?.showJobs ?? true),
_NavItem(
route: routeLabProducts,
icon: const Icon(Icons.inventory_2_outlined),
selectedIcon: const Icon(Icons.inventory_2_rounded),
label: s.productsTitle,
visible: (m) => m?.showProducts ?? true),
_NavItem(
route: routeLabFinance,
icon: const Icon(Icons.account_balance_outlined),
selectedIcon: const Icon(Icons.account_balance_rounded),
label: s.finance,
visible: (m) => m?.showFinance ?? true),
_NavItem(
route: routeLabSettings,
icon: const Icon(Icons.settings_outlined),
selectedIcon: const Icon(Icons.settings_rounded),
label: s.settings,
visible: (_) => true),
];
List<_SidebarEntry> _allEntries(AppStrings s) {
@@ -389,7 +561,8 @@ class _LabShellState extends ConsumerState<_LabShell> {
@override
Widget build(BuildContext context) {
final s = ref.watch(stringsProvider);
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
final isDesktop =
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
if (isDesktop) {
final entries = _allEntries(s);
@@ -413,7 +586,8 @@ class _LabShellState extends ConsumerState<_LabShell> {
// Mobile: only core items in bottom nav
final membership = ref.read(authProvider).activeTenant;
final items = _labMobileItems(s).where((it) => it.visible(membership)).toList();
final items =
_labMobileItems(s).where((it) => it.visible(membership)).toList();
final flatIndex = items.indexWhere((it) => it.route == _selectedRoute);
final clampedIndex = flatIndex >= 0 ? flatIndex : 0;
@@ -441,7 +615,10 @@ class _LabShellState extends ConsumerState<_LabShell> {
Semantics(
label: it.label,
button: true,
child: NavigationDestination(icon: it.icon, selectedIcon: it.selectedIcon, label: it.label),
child: NavigationDestination(
icon: it.icon,
selectedIcon: it.selectedIcon,
label: it.label),
),
],
),
@@ -483,7 +660,10 @@ class _DesktopSidebarState extends State<_DesktopSidebar> {
decoration: const BoxDecoration(
color: AppColors.surface,
border: Border(right: BorderSide(color: AppColors.border)),
boxShadow: [BoxShadow(color: Color(0x08000000), blurRadius: 8, offset: Offset(2, 0))],
boxShadow: [
BoxShadow(
color: Color(0x08000000), blurRadius: 8, offset: Offset(2, 0))
],
),
child: ClipRect(
child: Column(
@@ -492,7 +672,8 @@ class _DesktopSidebarState extends State<_DesktopSidebar> {
Container(
height: _DesktopSidebar.headerHeight,
decoration: const BoxDecoration(
gradient: LinearGradient(colors: [AppColors.primary, AppColors.accent]),
gradient: LinearGradient(
colors: [AppColors.primary, AppColors.accent]),
border: Border(bottom: BorderSide(color: AppColors.border)),
),
padding: const EdgeInsets.symmetric(horizontal: 16),
@@ -504,15 +685,21 @@ class _DesktopSidebarState extends State<_DesktopSidebar> {
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(9),
border: Border.all(color: Colors.white.withValues(alpha: 0.25)),
border: Border.all(
color: Colors.white.withValues(alpha: 0.25)),
),
child: const Center(child: ToothLogo(size: 18, color: Colors.white)),
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),
style: TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w800,
letterSpacing: 1),
),
],
],
@@ -525,8 +712,7 @@ class _DesktopSidebarState extends State<_DesktopSidebar> {
child: Column(
children: [
const SizedBox(height: 8),
for (final entry in widget.entries)
_buildEntry(entry),
for (final entry in widget.entries) _buildEntry(entry),
const SizedBox(height: 8),
],
),
@@ -545,17 +731,24 @@ class _DesktopSidebarState extends State<_DesktopSidebar> {
child: SizedBox(
height: 48,
child: Row(
mainAxisAlignment: _open ? MainAxisAlignment.start : MainAxisAlignment.center,
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),
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)),
const Text('Daralt',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.textMuted)),
],
],
),
@@ -629,7 +822,9 @@ class _SidebarItem extends StatelessWidget {
children: [
IconTheme(
data: IconThemeData(
color: selected ? AppColors.primary : AppColors.textSecondary,
color: selected
? AppColors.primary
: AppColors.textSecondary,
size: 20,
),
child: selected ? selectedIcon : icon,
@@ -639,8 +834,11 @@ class _SidebarItem extends StatelessWidget {
label,
style: TextStyle(
fontSize: 14,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected ? AppColors.primary : AppColors.textSecondary,
fontWeight:
selected ? FontWeight.w600 : FontWeight.w500,
color: selected
? AppColors.primary
: AppColors.textSecondary,
),
),
],
@@ -649,7 +847,9 @@ class _SidebarItem extends StatelessWidget {
: Center(
child: IconTheme(
data: IconThemeData(
color: selected ? AppColors.primary : AppColors.textSecondary,
color: selected
? AppColors.primary
: AppColors.textSecondary,
size: 20,
),
child: selected ? selectedIcon : icon,
@@ -740,7 +940,9 @@ class _SidebarGroupState extends State<_SidebarGroup> {
child: Icon(
isSelected ? widget.group.selectedIcon : widget.group.icon,
size: 20,
color: isSelected ? AppColors.primary : AppColors.textSecondary,
color: isSelected
? AppColors.primary
: AppColors.textSecondary,
),
),
),
@@ -768,20 +970,24 @@ class _SidebarGroupState extends State<_SidebarGroup> {
child: Row(
children: [
Icon(
isSelected ? widget.group.selectedIcon : widget.group.icon,
isSelected
? widget.group.selectedIcon
: widget.group.icon,
size: 20,
color: isSelected ? AppColors.primary : AppColors.textSecondary,
color: isSelected
? AppColors.primary
: AppColors.textSecondary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
widget.group.title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
child: Text(
widget.group.title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
),
AnimatedRotation(
duration: const Duration(milliseconds: 200),
@@ -803,7 +1009,8 @@ class _SidebarGroupState extends State<_SidebarGroup> {
// Sub-items (animated expand/collapse)
AnimatedCrossFade(
duration: const Duration(milliseconds: 200),
crossFadeState: _expanded ? CrossFadeState.showFirst : CrossFadeState.showSecond,
crossFadeState:
_expanded ? CrossFadeState.showFirst : CrossFadeState.showSecond,
firstChild: Column(
children: [
for (final item in widget.group.items)
+4 -3
View File
@@ -17,18 +17,19 @@ final routerProvider = Provider<GoRouter>((ref) {
return GoRouter(
refreshListenable: notifier,
initialLocation: routeSignIn,
initialLocation: routeWelcome,
redirect: (context, state) {
final auth = ref.read(authProvider);
if (auth.isLoading) return null;
final loc = state.matchedLocation;
final onLoginOrRegister = loc == routeSignIn || loc == routeSignUp;
final onLoginOrRegister =
loc == routeSignIn || loc == routeSignUp || loc == routeWelcome;
final onAuthPage = onLoginOrRegister || loc == routeOnboarding;
if (!auth.isAuthenticated) {
return onAuthPage ? null : routeSignIn;
return onAuthPage ? null : routeWelcome;
}
// Authenticated but no tenant → onboarding
+28 -12
View File
@@ -20,9 +20,9 @@ class FinanceService {
if (amount <= 0) return;
final existing = await _pb.collection('finance_entries').getFullList(
filter: 'job_id = "$jobId"',
batch: 200,
);
filter: 'job_id = "$jobId"',
batch: 200,
);
await _upsertEntry(
existing: existing,
@@ -47,11 +47,27 @@ class FinanceService {
);
}
Future<void> markJobPaid(String jobId) async {
Future<void> reportJobPayment(String jobId) async {
final existing = await _pb.collection('finance_entries').getFullList(
filter: 'job_id = "$jobId"',
batch: 200,
);
filter: 'job_id = "$jobId"',
batch: 200,
);
for (final record in existing) {
await _pb.collection('finance_entries').update(
record.id,
body: {
'status': 'reported',
'paid_at': null,
},
);
}
}
Future<void> confirmJobPayment(String jobId) async {
final existing = await _pb.collection('finance_entries').getFullList(
filter: 'job_id = "$jobId"',
batch: 200,
);
final paidAt = DateTime.now().toIso8601String();
for (final record in existing) {
await _pb.collection('finance_entries').update(
@@ -66,9 +82,10 @@ class FinanceService {
Future<void> deletePendingEntriesForJob(String jobId) async {
final existing = await _pb.collection('finance_entries').getFullList(
filter: 'job_id = "$jobId" && status = "pending"',
batch: 200,
);
filter:
'job_id = "$jobId" && (status = "pending" || status = "reported")',
batch: 200,
);
for (final record in existing) {
await _pb.collection('finance_entries').delete(record.id);
}
@@ -88,8 +105,7 @@ class FinanceService {
try {
match = existing.firstWhere(
(record) =>
record.data['tenant_id'] == tenantId &&
record.data['type'] == type,
record.data['tenant_id'] == tenantId && record.data['type'] == type,
);
} catch (_) {
match = null;
+27 -17
View File
@@ -19,6 +19,7 @@ class JobHistoryEntry {
enum JobHistoryAction {
accepted,
stepCompleted,
handedToClinic,
approved,
revisionRequested,
@@ -29,6 +30,7 @@ enum JobHistoryAction {
extension JobHistoryActionExt on JobHistoryAction {
String get value => switch (this) {
JobHistoryAction.accepted => 'accepted',
JobHistoryAction.stepCompleted => 'step_completed',
JobHistoryAction.handedToClinic => 'handed_to_clinic',
JobHistoryAction.approved => 'approved',
JobHistoryAction.revisionRequested => 'revision_requested',
@@ -43,21 +45,21 @@ class JobHistoryService {
PocketBase get _pb => PocketBaseClient.instance.pb;
String get _currentUserId =>
(_pb.authStore.record?.id) ?? (_pb.authStore.model as dynamic)?.id as String? ?? '';
String get _currentUserId => _pb.authStore.record?.id ?? '';
Future<List<JobHistoryEntry>> listForJob(String jobId) async {
try {
final result = await _pb.collection('job_status_history').getList(
filter: 'job_id = "$jobId"',
perPage: 200,
);
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? ?? ''),
@@ -65,30 +67,38 @@ class JobHistoryService {
note: str(j['note']),
createdAt: DateTime.parse(j['created'] as String),
);
}).toList()..sort((a, b) => a.createdAt.compareTo(b.createdAt)));
}).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,
'accepted' => JobHistoryAction.accepted,
'step_completed' => JobHistoryAction.stepCompleted,
'handed_to_clinic' => JobHistoryAction.handedToClinic,
'approved' => JobHistoryAction.approved,
'revision_requested' => JobHistoryAction.revisionRequested,
'delivered' => JobHistoryAction.delivered,
_ => JobHistoryAction.cancelled,
'delivered' => JobHistoryAction.delivered,
_ => JobHistoryAction.cancelled,
};
static JobStep _parseStep(String s) => switch (s) {
'olcu_kontrol' => JobStep.olcuKontrol,
'dijital_tasarim' => JobStep.dijitalTasarim,
'model_hazirlik' => JobStep.modelHazirlik,
'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,
'mum_prova' => JobStep.mumProva,
'disler_prova' => JobStep.dislerProva,
'dayanak_prova' => JobStep.dayanakProva,
'kron_prova' => JobStep.kronProva,
'fotograf_onay' => JobStep.fotografOnay,
'kalite_kontrol' => JobStep.kaliteKontrol,
'teslim_oncesi_kontrol' => JobStep.teslimOncesiKontrol,
'cila_bitim' => JobStep.cilaBitim,
_ => JobStep.olcu,
};
Future<void> append({
+17 -5
View File
@@ -16,21 +16,33 @@ class RealtimeService {
required void Function(RecordSubscriptionEvent) onEvent,
}) {
UnsubFn? cancel;
bool disposeRequested = false;
bool disposed = false;
_pb.collection(collection).subscribe(topic, onEvent, filter: filter).then((fn) {
_pb
.collection(collection)
.subscribe(topic, onEvent, filter: filter)
.then((fn) async {
if (disposeRequested) {
disposed = true;
await fn();
return;
}
cancel = fn;
});
}).catchError((_) {});
return () async {
if (disposed) return;
disposeRequested = true;
try {
final fn = cancel;
if (fn != null) {
disposed = true;
await fn();
} else {
await _pb.collection(collection).unsubscribe(topic);
}
} catch (_) {
await _pb.collection(collection).unsubscribe(topic);
// swallow — never globally unsubscribe the topic here, because
// other screens may still be subscribed to the same collection/topic.
}
};
}