diff --git a/README.md b/README.md index 04116b6a..597a8a4b 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,11 @@ This project uses a modular `Makefile` for all common tasks. - **[03-contributing.md](./docs/03-contributing.md)**: Guidelines for new developers and setup checklist. - **[04-sync-prototypes.md](./docs/04-sync-prototypes.md)**: How to sync prototypes for local dev and AI context. +### Mobile Development Documentation +- **[MOBILE/01-architecture-principles.md](./docs/MOBILE/01-architecture-principles.md)**: Flutter clean architecture, package roles, and dependency flow. +- **[MOBILE/02-design-system-usage.md](./docs/MOBILE/02-design-system-usage.md)**: Design system components and theming guidelines. +- **[MOBILE/00-agent-development-rules.md](./docs/MOBILE/00-agent-development-rules.md)**: Rules and best practices for mobile development. + ## 🤝 Contributing New to the team? Please read our **[Contributing Guide](./docs/03-contributing.md)** to get your environment set up and understand our workflow. diff --git a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart index 1269484c..3ba4a8ea 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart @@ -284,13 +284,38 @@ extension StaffNavigator on IModularNavigator { pushNamed(StaffPaths.faqs); } - /// Pushes the privacy and security settings page. + // ========================================================================== + // PRIVACY & SECURITY + // ========================================================================== + + /// Navigates to the privacy and security settings page. /// - /// Manage privacy preferences and security settings. - void toPrivacy() { - pushNamed(StaffPaths.privacy); + /// Manage privacy preferences including: + /// * Location sharing settings + /// * View terms of service + /// * View privacy policy + void toPrivacySecurity() { + pushNamed(StaffPaths.privacySecurity); } + /// Navigates to the Terms of Service page. + /// + /// Display the full terms of service document in a dedicated page view. + void toTermsOfService() { + pushNamed(StaffPaths.termsOfService); + } + + /// Navigates to the Privacy Policy page. + /// + /// Display the full privacy policy document in a dedicated page view. + void toPrivacyPolicy() { + pushNamed(StaffPaths.privacyPolicy); + } + + // ========================================================================== + // MESSAGING & COMMUNICATION + // ========================================================================== + /// Pushes the messages page (placeholder). /// /// Access internal messaging system. diff --git a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart index 1b49991c..97badf3c 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart @@ -203,10 +203,33 @@ class StaffPaths { static const String leaderboard = '/leaderboard'; /// FAQs - frequently asked questions. - static const String faqs = '/faqs'; + /// + /// Access to frequently asked questions about the staff application. + static const String faqs = '/worker-main/faqs/'; + + // ========================================================================== + // PRIVACY & SECURITY + // ========================================================================== /// Privacy and security settings. - static const String privacy = '/privacy'; + /// + /// Manage privacy preferences, location sharing, terms of service, + /// and privacy policy. + static const String privacySecurity = '/worker-main/privacy-security/'; + + /// Terms of Service page. + /// + /// Display the full terms of service document. + static const String termsOfService = '/worker-main/privacy-security/terms/'; + + /// Privacy Policy page. + /// + /// Display the full privacy policy document. + static const String privacyPolicy = '/worker-main/privacy-security/policy/'; + + // ========================================================================== + // MESSAGING & COMMUNICATION (Placeholders) + // ========================================================================== /// Messages - internal messaging system (placeholder). static const String messages = '/messages'; diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index e6ed7227..c3810474 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -521,7 +521,8 @@ "compliance": "COMPLIANCE", "level_up": "LEVEL UP", "finance": "FINANCE", - "support": "SUPPORT" + "support": "SUPPORT", + "settings": "SETTINGS" }, "menu_items": { "personal_info": "Personal Info", @@ -543,7 +544,8 @@ "timecard": "Timecard", "faqs": "FAQs", "privacy_security": "Privacy & Security", - "messages": "Messages" + "messages": "Messages", + "language": "Language" }, "bank_account_page": { "title": "Bank Account", @@ -1125,6 +1127,30 @@ "service_unavailable": "Service is currently unavailable." } }, + "staff_privacy_security": { + "title": "Privacy & Security", + "privacy_section": "Privacy", + "legal_section": "Legal", + "profile_visibility": { + "title": "Profile Visibility", + "subtitle": "Let clients see your profile" + }, + "terms_of_service": { + "title": "Terms of Service" + }, + "privacy_policy": { + "title": "Privacy Policy" + }, + "success": { + "profile_visibility_updated": "Profile visibility updated successfully!" + } + }, + "staff_faqs": { + "title": "FAQs", + "search_placeholder": "Search questions...", + "no_results": "No matching questions found", + "contact_support": "Contact Support" + }, "success": { "hub": { "created": "Hub created successfully!", diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 0540568a..17891b86 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -521,7 +521,8 @@ "compliance": "CUMPLIMIENTO", "level_up": "MEJORAR NIVEL", "finance": "FINANZAS", - "support": "SOPORTE" + "support": "SOPORTE", + "settings": "AJUSTES" }, "menu_items": { "personal_info": "Información Personal", @@ -543,7 +544,8 @@ "timecard": "Tarjeta de Tiempo", "faqs": "Preguntas Frecuentes", "privacy_security": "Privacidad y Seguridad", - "messages": "Mensajes" + "messages": "Mensajes", + "language": "Idioma" }, "bank_account_page": { "title": "Cuenta Bancaria", @@ -1125,6 +1127,30 @@ "service_unavailable": "El servicio no está disponible actualmente." } }, + "staff_privacy_security": { + "title": "Privacidad y Seguridad", + "privacy_section": "Privacidad", + "legal_section": "Legal", + "profile_visibility": { + "title": "Visibilidad del Perfil", + "subtitle": "Deja que los clientes vean tu perfil" + }, + "terms_of_service": { + "title": "Términos de Servicio" + }, + "privacy_policy": { + "title": "Política de Privacidad" + }, + "success": { + "profile_visibility_updated": "¡Visibilidad del perfil actualizada exitosamente!" + } + }, + "staff_faqs": { + "title": "Preguntas Frecuentes", + "search_placeholder": "Buscar preguntas...", + "no_results": "No se encontraron preguntas coincidentes", + "contact_support": "Contactar Soporte" + }, "success": { "hub": { "created": "¡Hub creado exitosamente!", diff --git a/apps/mobile/packages/design_system/lib/src/ui_colors.dart b/apps/mobile/packages/design_system/lib/src/ui_colors.dart index 30a56dc3..5bb0a5af 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_colors.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_colors.dart @@ -21,8 +21,8 @@ class UiColors { /// Foreground color on primary background (#F7FAFC) static const Color primaryForeground = Color(0xFFF7FAFC); - /// Inverse primary color (#9FABF1) - static const Color primaryInverse = Color(0xFF9FABF1); + /// Inverse primary color (#0A39DF) + static const Color primaryInverse = Color.fromARGB(23, 10, 56, 223); /// Secondary background color (#F1F3F5) static const Color secondary = Color(0xFFF1F3F5); diff --git a/apps/mobile/packages/design_system/lib/src/ui_icons.dart b/apps/mobile/packages/design_system/lib/src/ui_icons.dart index f73df6d4..55a7841b 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_icons.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_icons.dart @@ -267,4 +267,7 @@ class UiIcons { /// Chef hat icon for attire static const IconData chefHat = _IconLib.chefHat; + + /// Help circle icon for FAQs + static const IconData helpCircle = _IconLib.helpCircle; } diff --git a/apps/mobile/packages/design_system/lib/src/ui_theme.dart b/apps/mobile/packages/design_system/lib/src/ui_theme.dart index 919a78a0..6638cebe 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_theme.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_theme.dart @@ -71,7 +71,9 @@ class UiTheme { ), maximumSize: const Size(double.infinity, 54), ).copyWith( - side: WidgetStateProperty.resolveWith((Set states) { + side: WidgetStateProperty.resolveWith(( + Set states, + ) { if (states.contains(WidgetState.disabled)) { return const BorderSide( color: UiColors.borderPrimary, @@ -80,7 +82,9 @@ class UiTheme { } return null; }), - overlayColor: WidgetStateProperty.resolveWith((Set states) { + overlayColor: WidgetStateProperty.resolveWith(( + Set states, + ) { if (states.contains(WidgetState.hovered)) return UiColors.buttonPrimaryHover; return null; @@ -239,7 +243,9 @@ class UiTheme { navigationBarTheme: NavigationBarThemeData( backgroundColor: UiColors.white, indicatorColor: UiColors.primaryInverse.withAlpha(51), // 20% of 255 - labelTextStyle: WidgetStateProperty.resolveWith((Set states) { + labelTextStyle: WidgetStateProperty.resolveWith(( + Set states, + ) { if (states.contains(WidgetState.selected)) { return UiTypography.footnote2m.textPrimary; } @@ -249,13 +255,38 @@ class UiTheme { // Switch Theme switchTheme: SwitchThemeData( - trackColor: WidgetStateProperty.resolveWith((Set states) { + trackColor: WidgetStateProperty.resolveWith(( + Set states, + ) { if (states.contains(WidgetState.selected)) { - return UiColors.switchActive; + return UiColors.primary.withAlpha(60); } return UiColors.switchInactive; }), - thumbColor: const WidgetStatePropertyAll(UiColors.white), + thumbColor: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (states.contains(WidgetState.selected)) { + return UiColors.primary; + } + return UiColors.white; + }), + trackOutlineColor: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (states.contains(WidgetState.selected)) { + return UiColors.primary; + } + return UiColors.transparent; + }), + trackOutlineWidth: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (states.contains(WidgetState.selected)) { + return 1.0; + } + return 0.0; + }), ), // Checkbox Theme diff --git a/apps/mobile/packages/design_system/pubspec.yaml b/apps/mobile/packages/design_system/pubspec.yaml index 6bd42bb3..0979764c 100644 --- a/apps/mobile/packages/design_system/pubspec.yaml +++ b/apps/mobile/packages/design_system/pubspec.yaml @@ -15,8 +15,6 @@ dependencies: google_fonts: ^7.0.2 lucide_icons: ^0.257.0 font_awesome_flutter: ^10.7.0 - core_localization: - path: ../core_localization dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index d3b2ac2a..c604550c 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -37,6 +37,10 @@ export 'src/adapters/shifts/break/break_adapter.dart'; export 'src/entities/orders/order_type.dart'; export 'src/entities/orders/one_time_order.dart'; export 'src/entities/orders/one_time_order_position.dart'; +export 'src/entities/orders/recurring_order.dart'; +export 'src/entities/orders/recurring_order_position.dart'; +export 'src/entities/orders/permanent_order.dart'; +export 'src/entities/orders/permanent_order_position.dart'; export 'src/entities/orders/order_item.dart'; // Skills & Certs diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart new file mode 100644 index 00000000..f7712bc4 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart @@ -0,0 +1,96 @@ +import 'package:equatable/equatable.dart'; +import 'permanent_order_position.dart'; + +/// Represents a permanent staffing request spanning a date range. +class PermanentOrder extends Equatable { + const PermanentOrder({ + required this.startDate, + required this.permanentDays, + required this.location, + required this.positions, + this.hub, + this.eventName, + this.vendorId, + this.roleRates = const {}, + }); + + /// Start date for the permanent schedule. + final DateTime startDate; + + /// Days of the week to repeat on (e.g., ["SUN", "MON", ...]). + final List permanentDays; + + /// The primary location where the work will take place. + final String location; + + /// The list of positions and headcounts required for this order. + final List positions; + + /// Selected hub details for this order. + final PermanentOrderHubDetails? hub; + + /// Optional order name. + final String? eventName; + + /// Selected vendor id for this order. + final String? vendorId; + + /// Role hourly rates keyed by role id. + final Map roleRates; + + @override + List get props => [ + startDate, + permanentDays, + location, + positions, + hub, + eventName, + vendorId, + roleRates, + ]; +} + +/// Minimal hub details used during permanent order creation. +class PermanentOrderHubDetails extends Equatable { + const PermanentOrderHubDetails({ + required this.id, + required this.name, + required this.address, + this.placeId, + this.latitude, + this.longitude, + this.city, + this.state, + this.street, + this.country, + this.zipCode, + }); + + final String id; + final String name; + final String address; + final String? placeId; + final double? latitude; + final double? longitude; + final String? city; + final String? state; + final String? street; + final String? country; + final String? zipCode; + + @override + List get props => [ + id, + name, + address, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order_position.dart b/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order_position.dart new file mode 100644 index 00000000..fb4d1e1b --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order_position.dart @@ -0,0 +1,60 @@ +import 'package:equatable/equatable.dart'; + +/// Represents a specific position requirement within a [PermanentOrder]. +class PermanentOrderPosition extends Equatable { + const PermanentOrderPosition({ + required this.role, + required this.count, + required this.startTime, + required this.endTime, + this.lunchBreak = 'NO_BREAK', + this.location, + }); + + /// The job role or title required. + final String role; + + /// The number of workers required for this position. + final int count; + + /// The scheduled start time (e.g., "09:00 AM"). + final String startTime; + + /// The scheduled end time (e.g., "05:00 PM"). + final String endTime; + + /// The break duration enum value (e.g., NO_BREAK, MIN_15, MIN_30). + final String lunchBreak; + + /// Optional specific location for this position, if different from the order's main location. + final String? location; + + @override + List get props => [ + role, + count, + startTime, + endTime, + lunchBreak, + location, + ]; + + /// Creates a copy of this position with the given fields replaced. + PermanentOrderPosition copyWith({ + String? role, + int? count, + String? startTime, + String? endTime, + String? lunchBreak, + String? location, + }) { + return PermanentOrderPosition( + role: role ?? this.role, + count: count ?? this.count, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + lunchBreak: lunchBreak ?? this.lunchBreak, + location: location ?? this.location, + ); + } +} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart new file mode 100644 index 00000000..f11b63ec --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart @@ -0,0 +1,101 @@ +import 'package:equatable/equatable.dart'; +import 'recurring_order_position.dart'; + +/// Represents a recurring staffing request spanning a date range. +class RecurringOrder extends Equatable { + const RecurringOrder({ + required this.startDate, + required this.endDate, + required this.recurringDays, + required this.location, + required this.positions, + this.hub, + this.eventName, + this.vendorId, + this.roleRates = const {}, + }); + + /// Start date for the recurring schedule. + final DateTime startDate; + + /// End date for the recurring schedule. + final DateTime endDate; + + /// Days of the week to repeat on (e.g., ["S", "M", ...]). + final List recurringDays; + + /// The primary location where the work will take place. + final String location; + + /// The list of positions and headcounts required for this order. + final List positions; + + /// Selected hub details for this order. + final RecurringOrderHubDetails? hub; + + /// Optional order name. + final String? eventName; + + /// Selected vendor id for this order. + final String? vendorId; + + /// Role hourly rates keyed by role id. + final Map roleRates; + + @override + List get props => [ + startDate, + endDate, + recurringDays, + location, + positions, + hub, + eventName, + vendorId, + roleRates, + ]; +} + +/// Minimal hub details used during recurring order creation. +class RecurringOrderHubDetails extends Equatable { + const RecurringOrderHubDetails({ + required this.id, + required this.name, + required this.address, + this.placeId, + this.latitude, + this.longitude, + this.city, + this.state, + this.street, + this.country, + this.zipCode, + }); + + final String id; + final String name; + final String address; + final String? placeId; + final double? latitude; + final double? longitude; + final String? city; + final String? state; + final String? street; + final String? country; + final String? zipCode; + + @override + List get props => [ + id, + name, + address, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order_position.dart b/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order_position.dart new file mode 100644 index 00000000..9fdc2161 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order_position.dart @@ -0,0 +1,60 @@ +import 'package:equatable/equatable.dart'; + +/// Represents a specific position requirement within a [RecurringOrder]. +class RecurringOrderPosition extends Equatable { + const RecurringOrderPosition({ + required this.role, + required this.count, + required this.startTime, + required this.endTime, + this.lunchBreak = 'NO_BREAK', + this.location, + }); + + /// The job role or title required. + final String role; + + /// The number of workers required for this position. + final int count; + + /// The scheduled start time (e.g., "09:00 AM"). + final String startTime; + + /// The scheduled end time (e.g., "05:00 PM"). + final String endTime; + + /// The break duration enum value (e.g., NO_BREAK, MIN_15, MIN_30). + final String lunchBreak; + + /// Optional specific location for this position, if different from the order's main location. + final String? location; + + @override + List get props => [ + role, + count, + startTime, + endTime, + lunchBreak, + location, + ]; + + /// Creates a copy of this position with the given fields replaced. + RecurringOrderPosition copyWith({ + String? role, + int? count, + String? startTime, + String? endTime, + String? lunchBreak, + String? location, + }) { + return RecurringOrderPosition( + role: role ?? this.role, + count: count ?? this.count, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + lunchBreak: lunchBreak ?? this.lunchBreak, + location: location ?? this.location, + ); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/create_order_module.dart b/apps/mobile/packages/features/client/create_order/lib/src/create_order_module.dart index 0e2624e2..a99521d6 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/create_order_module.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/create_order_module.dart @@ -5,10 +5,14 @@ import 'package:krow_data_connect/krow_data_connect.dart'; import 'data/repositories_impl/client_create_order_repository_impl.dart'; import 'domain/repositories/client_create_order_repository_interface.dart'; import 'domain/usecases/create_one_time_order_usecase.dart'; +import 'domain/usecases/create_permanent_order_usecase.dart'; +import 'domain/usecases/create_recurring_order_usecase.dart'; import 'domain/usecases/create_rapid_order_usecase.dart'; import 'domain/usecases/get_order_types_usecase.dart'; import 'presentation/blocs/client_create_order_bloc.dart'; import 'presentation/blocs/one_time_order_bloc.dart'; +import 'presentation/blocs/permanent_order_bloc.dart'; +import 'presentation/blocs/recurring_order_bloc.dart'; import 'presentation/blocs/rapid_order_bloc.dart'; import 'presentation/pages/create_order_page.dart'; import 'presentation/pages/one_time_order_page.dart'; @@ -33,12 +37,16 @@ class ClientCreateOrderModule extends Module { // UseCases i.addLazySingleton(GetOrderTypesUseCase.new); i.addLazySingleton(CreateOneTimeOrderUseCase.new); + i.addLazySingleton(CreatePermanentOrderUseCase.new); + i.addLazySingleton(CreateRecurringOrderUseCase.new); i.addLazySingleton(CreateRapidOrderUseCase.new); // BLoCs i.add(ClientCreateOrderBloc.new); i.add(RapidOrderBloc.new); i.add(OneTimeOrderBloc.new); + i.add(PermanentOrderBloc.new); + i.add(RecurringOrderBloc.new); } @override diff --git a/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart b/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart index d5c90dea..757aff1f 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart @@ -33,16 +33,21 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte // titleKey: 'client_create_order.types.rapid', // descriptionKey: 'client_create_order.types.rapid_desc', // ), - // domain.OrderType( - // id: 'recurring', - // titleKey: 'client_create_order.types.recurring', - // descriptionKey: 'client_create_order.types.recurring_desc', - // ), + domain.OrderType( + id: 'recurring', + titleKey: 'client_create_order.types.recurring', + descriptionKey: 'client_create_order.types.recurring_desc', + ), // domain.OrderType( // id: 'permanent', // titleKey: 'client_create_order.types.permanent', // descriptionKey: 'client_create_order.types.permanent_desc', // ), + domain.OrderType( + id: 'permanent', + titleKey: 'client_create_order.types.permanent', + descriptionKey: 'client_create_order.types.permanent_desc', + ), ]); } @@ -100,7 +105,7 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte .state(hub.state) .street(hub.street) .country(hub.country) - .status(dc.ShiftStatus.PENDING) + .status(dc.ShiftStatus.CONFIRMED) .workersNeeded(workersNeeded) .filled(0) .durationDays(1) @@ -139,6 +144,246 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte }); } + @override + Future createRecurringOrder(domain.RecurringOrder order) async { + return _service.run(() async { + final String businessId = await _service.getBusinessId(); + final String? vendorId = order.vendorId; + if (vendorId == null || vendorId.isEmpty) { + throw Exception('Vendor is missing.'); + } + final domain.RecurringOrderHubDetails? hub = order.hub; + if (hub == null || hub.id.isEmpty) { + throw Exception('Hub is missing.'); + } + + final DateTime orderDateOnly = DateTime( + order.startDate.year, + order.startDate.month, + order.startDate.day, + ); + final fdc.Timestamp orderTimestamp = _service.toTimestamp(orderDateOnly); + final fdc.Timestamp startTimestamp = orderTimestamp; + final fdc.Timestamp endTimestamp = _service.toTimestamp(order.endDate); + + final fdc.OperationResult orderResult = + await _service.connector + .createOrder( + businessId: businessId, + orderType: dc.OrderType.RECURRING, + teamHubId: hub.id, + ) + .vendorId(vendorId) + .eventName(order.eventName) + .status(dc.OrderStatus.POSTED) + .date(orderTimestamp) + .startDate(startTimestamp) + .endDate(endTimestamp) + .recurringDays(order.recurringDays) + .execute(); + + final String orderId = orderResult.data.order_insert.id; + + // NOTE: Recurring orders are limited to 30 days of generated shifts. + // Future shifts beyond 30 days should be created by a scheduled job. + final DateTime maxEndDate = orderDateOnly.add(const Duration(days: 29)); + final DateTime effectiveEndDate = + order.endDate.isAfter(maxEndDate) ? maxEndDate : order.endDate; + + final Set selectedDays = Set.from(order.recurringDays); + final int workersNeeded = order.positions.fold( + 0, + (int sum, domain.RecurringOrderPosition position) => sum + position.count, + ); + final double shiftCost = _calculateRecurringShiftCost(order); + + final List shiftIds = []; + for (DateTime day = orderDateOnly; + !day.isAfter(effectiveEndDate); + day = day.add(const Duration(days: 1))) { + final String dayLabel = _weekdayLabel(day); + if (!selectedDays.contains(dayLabel)) { + continue; + } + + final String shiftTitle = 'Shift ${_formatDate(day)}'; + final fdc.Timestamp dayTimestamp = _service.toTimestamp( + DateTime(day.year, day.month, day.day), + ); + + final fdc.OperationResult shiftResult = + await _service.connector + .createShift(title: shiftTitle, orderId: orderId) + .date(dayTimestamp) + .location(hub.name) + .locationAddress(hub.address) + .latitude(hub.latitude) + .longitude(hub.longitude) + .placeId(hub.placeId) + .city(hub.city) + .state(hub.state) + .street(hub.street) + .country(hub.country) + .status(dc.ShiftStatus.CONFIRMED) + .workersNeeded(workersNeeded) + .filled(0) + .durationDays(1) + .cost(shiftCost) + .execute(); + + final String shiftId = shiftResult.data.shift_insert.id; + shiftIds.add(shiftId); + + for (final domain.RecurringOrderPosition position in order.positions) { + final DateTime start = _parseTime(day, position.startTime); + final DateTime end = _parseTime(day, position.endTime); + final DateTime normalizedEnd = + end.isBefore(start) ? end.add(const Duration(days: 1)) : end; + final double hours = normalizedEnd.difference(start).inMinutes / 60.0; + final double rate = order.roleRates[position.role] ?? 0; + final double totalValue = rate * hours * position.count; + + await _service.connector + .createShiftRole( + shiftId: shiftId, + roleId: position.role, + count: position.count, + ) + .startTime(_service.toTimestamp(start)) + .endTime(_service.toTimestamp(normalizedEnd)) + .hours(hours) + .breakType(_breakDurationFromValue(position.lunchBreak)) + .isBreakPaid(_isBreakPaid(position.lunchBreak)) + .totalValue(totalValue) + .execute(); + } + } + + await _service.connector + .updateOrder(id: orderId, teamHubId: hub.id) + .shifts(fdc.AnyValue(shiftIds)) + .execute(); + }); + } + + @override + Future createPermanentOrder(domain.PermanentOrder order) async { + return _service.run(() async { + final String businessId = await _service.getBusinessId(); + final String? vendorId = order.vendorId; + if (vendorId == null || vendorId.isEmpty) { + throw Exception('Vendor is missing.'); + } + final domain.PermanentOrderHubDetails? hub = order.hub; + if (hub == null || hub.id.isEmpty) { + throw Exception('Hub is missing.'); + } + + final DateTime orderDateOnly = DateTime( + order.startDate.year, + order.startDate.month, + order.startDate.day, + ); + final fdc.Timestamp orderTimestamp = _service.toTimestamp(orderDateOnly); + final fdc.Timestamp startTimestamp = orderTimestamp; + + final fdc.OperationResult orderResult = + await _service.connector + .createOrder( + businessId: businessId, + orderType: dc.OrderType.PERMANENT, + teamHubId: hub.id, + ) + .vendorId(vendorId) + .eventName(order.eventName) + .status(dc.OrderStatus.POSTED) + .date(orderTimestamp) + .startDate(startTimestamp) + .permanentDays(order.permanentDays) + .execute(); + + final String orderId = orderResult.data.order_insert.id; + + // NOTE: Permanent orders are limited to 30 days of generated shifts. + // Future shifts beyond 30 days should be created by a scheduled job. + final DateTime maxEndDate = orderDateOnly.add(const Duration(days: 29)); + + final Set selectedDays = Set.from(order.permanentDays); + final int workersNeeded = order.positions.fold( + 0, + (int sum, domain.PermanentOrderPosition position) => sum + position.count, + ); + final double shiftCost = _calculatePermanentShiftCost(order); + + final List shiftIds = []; + for (DateTime day = orderDateOnly; + !day.isAfter(maxEndDate); + day = day.add(const Duration(days: 1))) { + final String dayLabel = _weekdayLabel(day); + if (!selectedDays.contains(dayLabel)) { + continue; + } + + final String shiftTitle = 'Shift ${_formatDate(day)}'; + final fdc.Timestamp dayTimestamp = _service.toTimestamp( + DateTime(day.year, day.month, day.day), + ); + + final fdc.OperationResult shiftResult = + await _service.connector + .createShift(title: shiftTitle, orderId: orderId) + .date(dayTimestamp) + .location(hub.name) + .locationAddress(hub.address) + .latitude(hub.latitude) + .longitude(hub.longitude) + .placeId(hub.placeId) + .city(hub.city) + .state(hub.state) + .street(hub.street) + .country(hub.country) + .status(dc.ShiftStatus.CONFIRMED) + .workersNeeded(workersNeeded) + .filled(0) + .durationDays(1) + .cost(shiftCost) + .execute(); + + final String shiftId = shiftResult.data.shift_insert.id; + shiftIds.add(shiftId); + + for (final domain.PermanentOrderPosition position in order.positions) { + final DateTime start = _parseTime(day, position.startTime); + final DateTime end = _parseTime(day, position.endTime); + final DateTime normalizedEnd = + end.isBefore(start) ? end.add(const Duration(days: 1)) : end; + final double hours = normalizedEnd.difference(start).inMinutes / 60.0; + final double rate = order.roleRates[position.role] ?? 0; + final double totalValue = rate * hours * position.count; + + await _service.connector + .createShiftRole( + shiftId: shiftId, + roleId: position.role, + count: position.count, + ) + .startTime(_service.toTimestamp(start)) + .endTime(_service.toTimestamp(normalizedEnd)) + .hours(hours) + .breakType(_breakDurationFromValue(position.lunchBreak)) + .isBreakPaid(_isBreakPaid(position.lunchBreak)) + .totalValue(totalValue) + .execute(); + } + } + + await _service.connector + .updateOrder(id: orderId, teamHubId: hub.id) + .shifts(fdc.AnyValue(shiftIds)) + .execute(); + }); + } + @override Future createRapidOrder(String description) async { // TO-DO: connect IA and return array with the information. @@ -159,6 +404,54 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte return total; } + double _calculateRecurringShiftCost(domain.RecurringOrder order) { + double total = 0; + for (final domain.RecurringOrderPosition position in order.positions) { + final DateTime start = _parseTime(order.startDate, position.startTime); + final DateTime end = _parseTime(order.startDate, position.endTime); + final DateTime normalizedEnd = + end.isBefore(start) ? end.add(const Duration(days: 1)) : end; + final double hours = normalizedEnd.difference(start).inMinutes / 60.0; + final double rate = order.roleRates[position.role] ?? 0; + total += rate * hours * position.count; + } + return total; + } + + double _calculatePermanentShiftCost(domain.PermanentOrder order) { + double total = 0; + for (final domain.PermanentOrderPosition position in order.positions) { + final DateTime start = _parseTime(order.startDate, position.startTime); + final DateTime end = _parseTime(order.startDate, position.endTime); + final DateTime normalizedEnd = + end.isBefore(start) ? end.add(const Duration(days: 1)) : end; + final double hours = normalizedEnd.difference(start).inMinutes / 60.0; + final double rate = order.roleRates[position.role] ?? 0; + total += rate * hours * position.count; + } + return total; + } + + String _weekdayLabel(DateTime date) { + switch (date.weekday) { + case DateTime.monday: + return 'MON'; + case DateTime.tuesday: + return 'TUE'; + case DateTime.wednesday: + return 'WED'; + case DateTime.thursday: + return 'THU'; + case DateTime.friday: + return 'FRI'; + case DateTime.saturday: + return 'SAT'; + case DateTime.sunday: + default: + return 'SUN'; + } + } + dc.BreakDuration _breakDurationFromValue(String value) { switch (value) { case 'MIN_10': diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/arguments/permanent_order_arguments.dart b/apps/mobile/packages/features/client/create_order/lib/src/domain/arguments/permanent_order_arguments.dart new file mode 100644 index 00000000..0c0d5736 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/domain/arguments/permanent_order_arguments.dart @@ -0,0 +1,6 @@ +import 'package:krow_domain/krow_domain.dart'; + +class PermanentOrderArguments { + const PermanentOrderArguments({required this.order}); + final PermanentOrder order; +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/arguments/recurring_order_arguments.dart b/apps/mobile/packages/features/client/create_order/lib/src/domain/arguments/recurring_order_arguments.dart new file mode 100644 index 00000000..8c0c3d99 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/domain/arguments/recurring_order_arguments.dart @@ -0,0 +1,6 @@ +import 'package:krow_domain/krow_domain.dart'; + +class RecurringOrderArguments { + const RecurringOrderArguments({required this.order}); + final RecurringOrder order; +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/repositories/client_create_order_repository_interface.dart b/apps/mobile/packages/features/client/create_order/lib/src/domain/repositories/client_create_order_repository_interface.dart index 9f2fd567..0fe29f6b 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/domain/repositories/client_create_order_repository_interface.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/domain/repositories/client_create_order_repository_interface.dart @@ -17,6 +17,12 @@ abstract interface class ClientCreateOrderRepositoryInterface { /// [order] contains the date, location, and required positions. Future createOneTimeOrder(OneTimeOrder order); + /// Submits a recurring staffing order with specific details. + Future createRecurringOrder(RecurringOrder order); + + /// Submits a permanent staffing order with specific details. + Future createPermanentOrder(PermanentOrder order); + /// Submits a rapid (urgent) staffing order via a text description. /// /// [description] is the text message (or transcribed voice) describing the need. diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart new file mode 100644 index 00000000..b3afda92 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart @@ -0,0 +1,16 @@ +import 'package:krow_core/core.dart'; +import '../arguments/permanent_order_arguments.dart'; +import '../repositories/client_create_order_repository_interface.dart'; + +/// Use case for creating a permanent staffing order. +class CreatePermanentOrderUseCase + implements UseCase { + /// Creates a [CreatePermanentOrderUseCase]. + const CreatePermanentOrderUseCase(this._repository); + final ClientCreateOrderRepositoryInterface _repository; + + @override + Future call(PermanentOrderArguments input) { + return _repository.createPermanentOrder(input.order); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart new file mode 100644 index 00000000..f24c5841 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart @@ -0,0 +1,16 @@ +import 'package:krow_core/core.dart'; +import '../arguments/recurring_order_arguments.dart'; +import '../repositories/client_create_order_repository_interface.dart'; + +/// Use case for creating a recurring staffing order. +class CreateRecurringOrderUseCase + implements UseCase { + /// Creates a [CreateRecurringOrderUseCase]. + const CreateRecurringOrderUseCase(this._repository); + final ClientCreateOrderRepositoryInterface _repository; + + @override + Future call(RecurringOrderArguments input) { + return _repository.createRecurringOrder(input.order); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_bloc.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_bloc.dart new file mode 100644 index 00000000..731a8018 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_bloc.dart @@ -0,0 +1,338 @@ +import 'package:firebase_data_connect/firebase_data_connect.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_domain/krow_domain.dart' as domain; +import '../../domain/arguments/permanent_order_arguments.dart'; +import '../../domain/usecases/create_permanent_order_usecase.dart'; +import 'permanent_order_event.dart'; +import 'permanent_order_state.dart'; + +/// BLoC for managing the permanent order creation form. +class PermanentOrderBloc extends Bloc + with BlocErrorHandler, SafeBloc { + PermanentOrderBloc(this._createPermanentOrderUseCase, this._service) + : super(PermanentOrderState.initial()) { + on(_onVendorsLoaded); + on(_onVendorChanged); + on(_onHubsLoaded); + on(_onHubChanged); + on(_onEventNameChanged); + on(_onStartDateChanged); + on(_onDayToggled); + on(_onPositionAdded); + on(_onPositionRemoved); + on(_onPositionUpdated); + on(_onSubmitted); + + _loadVendors(); + _loadHubs(); + } + + final CreatePermanentOrderUseCase _createPermanentOrderUseCase; + final dc.DataConnectService _service; + + static const List _dayLabels = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + + Future _loadVendors() async { + final List? vendors = await handleErrorWithResult( + action: () async { + final QueryResult result = + await _service.connector.listVendors().execute(); + return result.data.vendors + .map( + (dc.ListVendorsVendors vendor) => domain.Vendor( + id: vendor.id, + name: vendor.companyName, + rates: const {}, + ), + ) + .toList(); + }, + onError: (_) => add(const PermanentOrderVendorsLoaded([])), + ); + + if (vendors != null) { + add(PermanentOrderVendorsLoaded(vendors)); + } + } + + Future _loadRolesForVendor( + String vendorId, + Emitter emit, + ) async { + final List? roles = await handleErrorWithResult( + action: () async { + final QueryResult + result = await _service.connector + .listRolesByVendorId(vendorId: vendorId) + .execute(); + return result.data.roles + .map( + (dc.ListRolesByVendorIdRoles role) => PermanentOrderRoleOption( + id: role.id, + name: role.name, + costPerHour: role.costPerHour, + ), + ) + .toList(); + }, + onError: (_) => emit(state.copyWith(roles: const [])), + ); + + if (roles != null) { + emit(state.copyWith(roles: roles)); + } + } + + Future _loadHubs() async { + final List? hubs = await handleErrorWithResult( + action: () async { + final String businessId = await _service.getBusinessId(); + final QueryResult + result = await _service.connector + .listTeamHubsByOwnerId(ownerId: businessId) + .execute(); + return result.data.teamHubs + .map( + (dc.ListTeamHubsByOwnerIdTeamHubs hub) => PermanentOrderHubOption( + id: hub.id, + name: hub.hubName, + address: hub.address, + placeId: hub.placeId, + latitude: hub.latitude, + longitude: hub.longitude, + city: hub.city, + state: hub.state, + street: hub.street, + country: hub.country, + zipCode: hub.zipCode, + ), + ) + .toList(); + }, + onError: (_) => add(const PermanentOrderHubsLoaded([])), + ); + + if (hubs != null) { + add(PermanentOrderHubsLoaded(hubs)); + } + } + + Future _onVendorsLoaded( + PermanentOrderVendorsLoaded event, + Emitter emit, + ) async { + final domain.Vendor? selectedVendor = + event.vendors.isNotEmpty ? event.vendors.first : null; + emit( + state.copyWith( + vendors: event.vendors, + selectedVendor: selectedVendor, + ), + ); + if (selectedVendor != null) { + await _loadRolesForVendor(selectedVendor.id, emit); + } + } + + Future _onVendorChanged( + PermanentOrderVendorChanged event, + Emitter emit, + ) async { + emit(state.copyWith(selectedVendor: event.vendor)); + await _loadRolesForVendor(event.vendor.id, emit); + } + + void _onHubsLoaded( + PermanentOrderHubsLoaded event, + Emitter emit, + ) { + final PermanentOrderHubOption? selectedHub = + event.hubs.isNotEmpty ? event.hubs.first : null; + emit( + state.copyWith( + hubs: event.hubs, + selectedHub: selectedHub, + location: selectedHub?.name ?? '', + ), + ); + } + + void _onHubChanged( + PermanentOrderHubChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + selectedHub: event.hub, + location: event.hub.name, + ), + ); + } + + void _onEventNameChanged( + PermanentOrderEventNameChanged event, + Emitter emit, + ) { + emit(state.copyWith(eventName: event.eventName)); + } + + void _onStartDateChanged( + PermanentOrderStartDateChanged event, + Emitter emit, + ) { + final int newDayIndex = event.date.weekday % 7; + final int? autoIndex = state.autoSelectedDayIndex; + List days = List.from(state.permanentDays); + if (autoIndex != null) { + final String oldDay = _dayLabels[autoIndex]; + days.remove(oldDay); + final String newDay = _dayLabels[newDayIndex]; + if (!days.contains(newDay)) { + days.add(newDay); + } + days = _sortDays(days); + } + emit( + state.copyWith( + startDate: event.date, + permanentDays: days, + autoSelectedDayIndex: autoIndex == null ? null : newDayIndex, + ), + ); + } + + void _onDayToggled( + PermanentOrderDayToggled event, + Emitter emit, + ) { + final List days = List.from(state.permanentDays); + final String label = _dayLabels[event.dayIndex]; + int? autoIndex = state.autoSelectedDayIndex; + if (days.contains(label)) { + days.remove(label); + if (autoIndex == event.dayIndex) { + autoIndex = null; + } + } else { + days.add(label); + } + emit(state.copyWith(permanentDays: _sortDays(days), autoSelectedDayIndex: autoIndex)); + } + + void _onPositionAdded( + PermanentOrderPositionAdded event, + Emitter emit, + ) { + final List newPositions = + List.from(state.positions)..add( + const PermanentOrderPosition( + role: '', + count: 1, + startTime: '09:00', + endTime: '17:00', + ), + ); + emit(state.copyWith(positions: newPositions)); + } + + void _onPositionRemoved( + PermanentOrderPositionRemoved event, + Emitter emit, + ) { + if (state.positions.length > 1) { + final List newPositions = + List.from(state.positions) + ..removeAt(event.index); + emit(state.copyWith(positions: newPositions)); + } + } + + void _onPositionUpdated( + PermanentOrderPositionUpdated event, + Emitter emit, + ) { + final List newPositions = + List.from(state.positions); + newPositions[event.index] = event.position; + emit(state.copyWith(positions: newPositions)); + } + + Future _onSubmitted( + PermanentOrderSubmitted event, + Emitter emit, + ) async { + emit(state.copyWith(status: PermanentOrderStatus.loading)); + await handleError( + emit: emit, + action: () async { + final Map roleRates = { + for (final PermanentOrderRoleOption role in state.roles) + role.id: role.costPerHour, + }; + final PermanentOrderHubOption? selectedHub = state.selectedHub; + if (selectedHub == null) { + throw domain.OrderMissingHubException(); + } + final domain.PermanentOrder order = domain.PermanentOrder( + startDate: state.startDate, + permanentDays: state.permanentDays, + location: selectedHub.name, + positions: state.positions + .map( + (PermanentOrderPosition p) => domain.PermanentOrderPosition( + role: p.role, + count: p.count, + startTime: p.startTime, + endTime: p.endTime, + lunchBreak: p.lunchBreak ?? 'NO_BREAK', + location: null, + ), + ) + .toList(), + hub: domain.PermanentOrderHubDetails( + id: selectedHub.id, + name: selectedHub.name, + address: selectedHub.address, + placeId: selectedHub.placeId, + latitude: selectedHub.latitude, + longitude: selectedHub.longitude, + city: selectedHub.city, + state: selectedHub.state, + street: selectedHub.street, + country: selectedHub.country, + zipCode: selectedHub.zipCode, + ), + eventName: state.eventName, + vendorId: state.selectedVendor?.id, + roleRates: roleRates, + ); + await _createPermanentOrderUseCase( + PermanentOrderArguments(order: order), + ); + emit(state.copyWith(status: PermanentOrderStatus.success)); + }, + onError: (String errorKey) => state.copyWith( + status: PermanentOrderStatus.failure, + errorMessage: errorKey, + ), + ); + } + + static List _sortDays(List days) { + days.sort( + (String a, String b) => + _dayLabels.indexOf(a).compareTo(_dayLabels.indexOf(b)), + ); + return days; + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_event.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_event.dart new file mode 100644 index 00000000..bcf98127 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_event.dart @@ -0,0 +1,100 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart' show Vendor; +import 'permanent_order_state.dart'; + +abstract class PermanentOrderEvent extends Equatable { + const PermanentOrderEvent(); + + @override + List get props => []; +} + +class PermanentOrderVendorsLoaded extends PermanentOrderEvent { + const PermanentOrderVendorsLoaded(this.vendors); + + final List vendors; + + @override + List get props => [vendors]; +} + +class PermanentOrderVendorChanged extends PermanentOrderEvent { + const PermanentOrderVendorChanged(this.vendor); + + final Vendor vendor; + + @override + List get props => [vendor]; +} + +class PermanentOrderHubsLoaded extends PermanentOrderEvent { + const PermanentOrderHubsLoaded(this.hubs); + + final List hubs; + + @override + List get props => [hubs]; +} + +class PermanentOrderHubChanged extends PermanentOrderEvent { + const PermanentOrderHubChanged(this.hub); + + final PermanentOrderHubOption hub; + + @override + List get props => [hub]; +} + +class PermanentOrderEventNameChanged extends PermanentOrderEvent { + const PermanentOrderEventNameChanged(this.eventName); + + final String eventName; + + @override + List get props => [eventName]; +} + +class PermanentOrderStartDateChanged extends PermanentOrderEvent { + const PermanentOrderStartDateChanged(this.date); + + final DateTime date; + + @override + List get props => [date]; +} + +class PermanentOrderDayToggled extends PermanentOrderEvent { + const PermanentOrderDayToggled(this.dayIndex); + + final int dayIndex; + + @override + List get props => [dayIndex]; +} + +class PermanentOrderPositionAdded extends PermanentOrderEvent { + const PermanentOrderPositionAdded(); +} + +class PermanentOrderPositionRemoved extends PermanentOrderEvent { + const PermanentOrderPositionRemoved(this.index); + + final int index; + + @override + List get props => [index]; +} + +class PermanentOrderPositionUpdated extends PermanentOrderEvent { + const PermanentOrderPositionUpdated(this.index, this.position); + + final int index; + final PermanentOrderPosition position; + + @override + List get props => [index, position]; +} + +class PermanentOrderSubmitted extends PermanentOrderEvent { + const PermanentOrderSubmitted(); +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_state.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_state.dart new file mode 100644 index 00000000..38dc743e --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_state.dart @@ -0,0 +1,221 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +enum PermanentOrderStatus { initial, loading, success, failure } + +class PermanentOrderState extends Equatable { + const PermanentOrderState({ + required this.startDate, + required this.permanentDays, + required this.location, + required this.eventName, + required this.positions, + required this.autoSelectedDayIndex, + this.status = PermanentOrderStatus.initial, + this.errorMessage, + this.vendors = const [], + this.selectedVendor, + this.hubs = const [], + this.selectedHub, + this.roles = const [], + }); + + factory PermanentOrderState.initial() { + final DateTime now = DateTime.now(); + final DateTime start = DateTime(now.year, now.month, now.day); + final List dayLabels = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + final int weekdayIndex = now.weekday % 7; + return PermanentOrderState( + startDate: start, + permanentDays: [dayLabels[weekdayIndex]], + location: '', + eventName: '', + positions: const [ + PermanentOrderPosition(role: '', count: 1, startTime: '', endTime: ''), + ], + autoSelectedDayIndex: weekdayIndex, + vendors: const [], + hubs: const [], + roles: const [], + ); + } + + final DateTime startDate; + final List permanentDays; + final String location; + final String eventName; + final List positions; + final int? autoSelectedDayIndex; + final PermanentOrderStatus status; + final String? errorMessage; + final List vendors; + final Vendor? selectedVendor; + final List hubs; + final PermanentOrderHubOption? selectedHub; + final List roles; + + PermanentOrderState copyWith({ + DateTime? startDate, + List? permanentDays, + String? location, + String? eventName, + List? positions, + int? autoSelectedDayIndex, + PermanentOrderStatus? status, + String? errorMessage, + List? vendors, + Vendor? selectedVendor, + List? hubs, + PermanentOrderHubOption? selectedHub, + List? roles, + }) { + return PermanentOrderState( + startDate: startDate ?? this.startDate, + permanentDays: permanentDays ?? this.permanentDays, + location: location ?? this.location, + eventName: eventName ?? this.eventName, + positions: positions ?? this.positions, + autoSelectedDayIndex: autoSelectedDayIndex ?? this.autoSelectedDayIndex, + status: status ?? this.status, + errorMessage: errorMessage ?? this.errorMessage, + vendors: vendors ?? this.vendors, + selectedVendor: selectedVendor ?? this.selectedVendor, + hubs: hubs ?? this.hubs, + selectedHub: selectedHub ?? this.selectedHub, + roles: roles ?? this.roles, + ); + } + + bool get isValid { + return eventName.isNotEmpty && + selectedVendor != null && + selectedHub != null && + positions.isNotEmpty && + permanentDays.isNotEmpty && + positions.every( + (PermanentOrderPosition p) => + p.role.isNotEmpty && + p.count > 0 && + p.startTime.isNotEmpty && + p.endTime.isNotEmpty, + ); + } + + @override + List get props => [ + startDate, + permanentDays, + location, + eventName, + positions, + autoSelectedDayIndex, + status, + errorMessage, + vendors, + selectedVendor, + hubs, + selectedHub, + roles, + ]; +} + +class PermanentOrderHubOption extends Equatable { + const PermanentOrderHubOption({ + required this.id, + required this.name, + required this.address, + this.placeId, + this.latitude, + this.longitude, + this.city, + this.state, + this.street, + this.country, + this.zipCode, + }); + + final String id; + final String name; + final String address; + final String? placeId; + final double? latitude; + final double? longitude; + final String? city; + final String? state; + final String? street; + final String? country; + final String? zipCode; + + @override + List get props => [ + id, + name, + address, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + ]; +} + +class PermanentOrderRoleOption extends Equatable { + const PermanentOrderRoleOption({ + required this.id, + required this.name, + required this.costPerHour, + }); + + final String id; + final String name; + final double costPerHour; + + @override + List get props => [id, name, costPerHour]; +} + +class PermanentOrderPosition extends Equatable { + const PermanentOrderPosition({ + required this.role, + required this.count, + required this.startTime, + required this.endTime, + this.lunchBreak, + }); + + final String role; + final int count; + final String startTime; + final String endTime; + final String? lunchBreak; + + PermanentOrderPosition copyWith({ + String? role, + int? count, + String? startTime, + String? endTime, + String? lunchBreak, + }) { + return PermanentOrderPosition( + role: role ?? this.role, + count: count ?? this.count, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + lunchBreak: lunchBreak ?? this.lunchBreak, + ); + } + + @override + List get props => [role, count, startTime, endTime, lunchBreak]; +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_bloc.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_bloc.dart new file mode 100644 index 00000000..b94ed6c1 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_bloc.dart @@ -0,0 +1,356 @@ +import 'package:firebase_data_connect/firebase_data_connect.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_domain/krow_domain.dart' as domain; +import '../../domain/arguments/recurring_order_arguments.dart'; +import '../../domain/usecases/create_recurring_order_usecase.dart'; +import 'recurring_order_event.dart'; +import 'recurring_order_state.dart'; + +/// BLoC for managing the recurring order creation form. +class RecurringOrderBloc extends Bloc + with BlocErrorHandler, SafeBloc { + RecurringOrderBloc(this._createRecurringOrderUseCase, this._service) + : super(RecurringOrderState.initial()) { + on(_onVendorsLoaded); + on(_onVendorChanged); + on(_onHubsLoaded); + on(_onHubChanged); + on(_onEventNameChanged); + on(_onStartDateChanged); + on(_onEndDateChanged); + on(_onDayToggled); + on(_onPositionAdded); + on(_onPositionRemoved); + on(_onPositionUpdated); + on(_onSubmitted); + + _loadVendors(); + _loadHubs(); + } + + final CreateRecurringOrderUseCase _createRecurringOrderUseCase; + final dc.DataConnectService _service; + + static const List _dayLabels = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + + Future _loadVendors() async { + final List? vendors = await handleErrorWithResult( + action: () async { + final QueryResult result = + await _service.connector.listVendors().execute(); + return result.data.vendors + .map( + (dc.ListVendorsVendors vendor) => domain.Vendor( + id: vendor.id, + name: vendor.companyName, + rates: const {}, + ), + ) + .toList(); + }, + onError: (_) => add(const RecurringOrderVendorsLoaded([])), + ); + + if (vendors != null) { + add(RecurringOrderVendorsLoaded(vendors)); + } + } + + Future _loadRolesForVendor( + String vendorId, + Emitter emit, + ) async { + final List? roles = await handleErrorWithResult( + action: () async { + final QueryResult + result = await _service.connector + .listRolesByVendorId(vendorId: vendorId) + .execute(); + return result.data.roles + .map( + (dc.ListRolesByVendorIdRoles role) => RecurringOrderRoleOption( + id: role.id, + name: role.name, + costPerHour: role.costPerHour, + ), + ) + .toList(); + }, + onError: (_) => emit(state.copyWith(roles: const [])), + ); + + if (roles != null) { + emit(state.copyWith(roles: roles)); + } + } + + Future _loadHubs() async { + final List? hubs = await handleErrorWithResult( + action: () async { + final String businessId = await _service.getBusinessId(); + final QueryResult + result = await _service.connector + .listTeamHubsByOwnerId(ownerId: businessId) + .execute(); + return result.data.teamHubs + .map( + (dc.ListTeamHubsByOwnerIdTeamHubs hub) => RecurringOrderHubOption( + id: hub.id, + name: hub.hubName, + address: hub.address, + placeId: hub.placeId, + latitude: hub.latitude, + longitude: hub.longitude, + city: hub.city, + state: hub.state, + street: hub.street, + country: hub.country, + zipCode: hub.zipCode, + ), + ) + .toList(); + }, + onError: (_) => add(const RecurringOrderHubsLoaded([])), + ); + + if (hubs != null) { + add(RecurringOrderHubsLoaded(hubs)); + } + } + + Future _onVendorsLoaded( + RecurringOrderVendorsLoaded event, + Emitter emit, + ) async { + final domain.Vendor? selectedVendor = + event.vendors.isNotEmpty ? event.vendors.first : null; + emit( + state.copyWith( + vendors: event.vendors, + selectedVendor: selectedVendor, + ), + ); + if (selectedVendor != null) { + await _loadRolesForVendor(selectedVendor.id, emit); + } + } + + Future _onVendorChanged( + RecurringOrderVendorChanged event, + Emitter emit, + ) async { + emit(state.copyWith(selectedVendor: event.vendor)); + await _loadRolesForVendor(event.vendor.id, emit); + } + + void _onHubsLoaded( + RecurringOrderHubsLoaded event, + Emitter emit, + ) { + final RecurringOrderHubOption? selectedHub = + event.hubs.isNotEmpty ? event.hubs.first : null; + emit( + state.copyWith( + hubs: event.hubs, + selectedHub: selectedHub, + location: selectedHub?.name ?? '', + ), + ); + } + + void _onHubChanged( + RecurringOrderHubChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + selectedHub: event.hub, + location: event.hub.name, + ), + ); + } + + void _onEventNameChanged( + RecurringOrderEventNameChanged event, + Emitter emit, + ) { + emit(state.copyWith(eventName: event.eventName)); + } + + void _onStartDateChanged( + RecurringOrderStartDateChanged event, + Emitter emit, + ) { + DateTime endDate = state.endDate; + if (endDate.isBefore(event.date)) { + endDate = event.date; + } + final int newDayIndex = event.date.weekday % 7; + final int? autoIndex = state.autoSelectedDayIndex; + List days = List.from(state.recurringDays); + if (autoIndex != null) { + final String oldDay = _dayLabels[autoIndex]; + days.remove(oldDay); + final String newDay = _dayLabels[newDayIndex]; + if (!days.contains(newDay)) { + days.add(newDay); + } + days = _sortDays(days); + } + emit( + state.copyWith( + startDate: event.date, + endDate: endDate, + recurringDays: days, + autoSelectedDayIndex: autoIndex == null ? null : newDayIndex, + ), + ); + } + + void _onEndDateChanged( + RecurringOrderEndDateChanged event, + Emitter emit, + ) { + DateTime startDate = state.startDate; + if (event.date.isBefore(startDate)) { + startDate = event.date; + } + emit(state.copyWith(endDate: event.date, startDate: startDate)); + } + + void _onDayToggled( + RecurringOrderDayToggled event, + Emitter emit, + ) { + final List days = List.from(state.recurringDays); + final String label = _dayLabels[event.dayIndex]; + int? autoIndex = state.autoSelectedDayIndex; + if (days.contains(label)) { + days.remove(label); + if (autoIndex == event.dayIndex) { + autoIndex = null; + } + } else { + days.add(label); + } + emit(state.copyWith(recurringDays: _sortDays(days), autoSelectedDayIndex: autoIndex)); + } + + void _onPositionAdded( + RecurringOrderPositionAdded event, + Emitter emit, + ) { + final List newPositions = + List.from(state.positions)..add( + const RecurringOrderPosition( + role: '', + count: 1, + startTime: '09:00', + endTime: '17:00', + ), + ); + emit(state.copyWith(positions: newPositions)); + } + + void _onPositionRemoved( + RecurringOrderPositionRemoved event, + Emitter emit, + ) { + if (state.positions.length > 1) { + final List newPositions = + List.from(state.positions) + ..removeAt(event.index); + emit(state.copyWith(positions: newPositions)); + } + } + + void _onPositionUpdated( + RecurringOrderPositionUpdated event, + Emitter emit, + ) { + final List newPositions = + List.from(state.positions); + newPositions[event.index] = event.position; + emit(state.copyWith(positions: newPositions)); + } + + Future _onSubmitted( + RecurringOrderSubmitted event, + Emitter emit, + ) async { + emit(state.copyWith(status: RecurringOrderStatus.loading)); + await handleError( + emit: emit, + action: () async { + final Map roleRates = { + for (final RecurringOrderRoleOption role in state.roles) + role.id: role.costPerHour, + }; + final RecurringOrderHubOption? selectedHub = state.selectedHub; + if (selectedHub == null) { + throw domain.OrderMissingHubException(); + } + final domain.RecurringOrder order = domain.RecurringOrder( + startDate: state.startDate, + endDate: state.endDate, + recurringDays: state.recurringDays, + location: selectedHub.name, + positions: state.positions + .map( + (RecurringOrderPosition p) => domain.RecurringOrderPosition( + role: p.role, + count: p.count, + startTime: p.startTime, + endTime: p.endTime, + lunchBreak: p.lunchBreak ?? 'NO_BREAK', + location: null, + ), + ) + .toList(), + hub: domain.RecurringOrderHubDetails( + id: selectedHub.id, + name: selectedHub.name, + address: selectedHub.address, + placeId: selectedHub.placeId, + latitude: selectedHub.latitude, + longitude: selectedHub.longitude, + city: selectedHub.city, + state: selectedHub.state, + street: selectedHub.street, + country: selectedHub.country, + zipCode: selectedHub.zipCode, + ), + eventName: state.eventName, + vendorId: state.selectedVendor?.id, + roleRates: roleRates, + ); + await _createRecurringOrderUseCase( + RecurringOrderArguments(order: order), + ); + emit(state.copyWith(status: RecurringOrderStatus.success)); + }, + onError: (String errorKey) => state.copyWith( + status: RecurringOrderStatus.failure, + errorMessage: errorKey, + ), + ); + } + + static List _sortDays(List days) { + days.sort( + (String a, String b) => + _dayLabels.indexOf(a).compareTo(_dayLabels.indexOf(b)), + ); + return days; + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_event.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_event.dart new file mode 100644 index 00000000..3803153a --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_event.dart @@ -0,0 +1,109 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart' show Vendor; +import 'recurring_order_state.dart'; + +abstract class RecurringOrderEvent extends Equatable { + const RecurringOrderEvent(); + + @override + List get props => []; +} + +class RecurringOrderVendorsLoaded extends RecurringOrderEvent { + const RecurringOrderVendorsLoaded(this.vendors); + + final List vendors; + + @override + List get props => [vendors]; +} + +class RecurringOrderVendorChanged extends RecurringOrderEvent { + const RecurringOrderVendorChanged(this.vendor); + + final Vendor vendor; + + @override + List get props => [vendor]; +} + +class RecurringOrderHubsLoaded extends RecurringOrderEvent { + const RecurringOrderHubsLoaded(this.hubs); + + final List hubs; + + @override + List get props => [hubs]; +} + +class RecurringOrderHubChanged extends RecurringOrderEvent { + const RecurringOrderHubChanged(this.hub); + + final RecurringOrderHubOption hub; + + @override + List get props => [hub]; +} + +class RecurringOrderEventNameChanged extends RecurringOrderEvent { + const RecurringOrderEventNameChanged(this.eventName); + + final String eventName; + + @override + List get props => [eventName]; +} + +class RecurringOrderStartDateChanged extends RecurringOrderEvent { + const RecurringOrderStartDateChanged(this.date); + + final DateTime date; + + @override + List get props => [date]; +} + +class RecurringOrderEndDateChanged extends RecurringOrderEvent { + const RecurringOrderEndDateChanged(this.date); + + final DateTime date; + + @override + List get props => [date]; +} + +class RecurringOrderDayToggled extends RecurringOrderEvent { + const RecurringOrderDayToggled(this.dayIndex); + + final int dayIndex; + + @override + List get props => [dayIndex]; +} + +class RecurringOrderPositionAdded extends RecurringOrderEvent { + const RecurringOrderPositionAdded(); +} + +class RecurringOrderPositionRemoved extends RecurringOrderEvent { + const RecurringOrderPositionRemoved(this.index); + + final int index; + + @override + List get props => [index]; +} + +class RecurringOrderPositionUpdated extends RecurringOrderEvent { + const RecurringOrderPositionUpdated(this.index, this.position); + + final int index; + final RecurringOrderPosition position; + + @override + List get props => [index, position]; +} + +class RecurringOrderSubmitted extends RecurringOrderEvent { + const RecurringOrderSubmitted(); +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_state.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_state.dart new file mode 100644 index 00000000..626beae8 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_state.dart @@ -0,0 +1,229 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +enum RecurringOrderStatus { initial, loading, success, failure } + +class RecurringOrderState extends Equatable { + const RecurringOrderState({ + required this.startDate, + required this.endDate, + required this.recurringDays, + required this.location, + required this.eventName, + required this.positions, + required this.autoSelectedDayIndex, + this.status = RecurringOrderStatus.initial, + this.errorMessage, + this.vendors = const [], + this.selectedVendor, + this.hubs = const [], + this.selectedHub, + this.roles = const [], + }); + + factory RecurringOrderState.initial() { + final DateTime now = DateTime.now(); + final DateTime start = DateTime(now.year, now.month, now.day); + final List dayLabels = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + final int weekdayIndex = now.weekday % 7; + return RecurringOrderState( + startDate: start, + endDate: start.add(const Duration(days: 7)), + recurringDays: [dayLabels[weekdayIndex]], + location: '', + eventName: '', + positions: const [ + RecurringOrderPosition(role: '', count: 1, startTime: '', endTime: ''), + ], + autoSelectedDayIndex: weekdayIndex, + vendors: const [], + hubs: const [], + roles: const [], + ); + } + + final DateTime startDate; + final DateTime endDate; + final List recurringDays; + final String location; + final String eventName; + final List positions; + final int? autoSelectedDayIndex; + final RecurringOrderStatus status; + final String? errorMessage; + final List vendors; + final Vendor? selectedVendor; + final List hubs; + final RecurringOrderHubOption? selectedHub; + final List roles; + + RecurringOrderState copyWith({ + DateTime? startDate, + DateTime? endDate, + List? recurringDays, + String? location, + String? eventName, + List? positions, + int? autoSelectedDayIndex, + RecurringOrderStatus? status, + String? errorMessage, + List? vendors, + Vendor? selectedVendor, + List? hubs, + RecurringOrderHubOption? selectedHub, + List? roles, + }) { + return RecurringOrderState( + startDate: startDate ?? this.startDate, + endDate: endDate ?? this.endDate, + recurringDays: recurringDays ?? this.recurringDays, + location: location ?? this.location, + eventName: eventName ?? this.eventName, + positions: positions ?? this.positions, + autoSelectedDayIndex: autoSelectedDayIndex ?? this.autoSelectedDayIndex, + status: status ?? this.status, + errorMessage: errorMessage ?? this.errorMessage, + vendors: vendors ?? this.vendors, + selectedVendor: selectedVendor ?? this.selectedVendor, + hubs: hubs ?? this.hubs, + selectedHub: selectedHub ?? this.selectedHub, + roles: roles ?? this.roles, + ); + } + + bool get isValid { + final bool datesValid = !endDate.isBefore(startDate); + return eventName.isNotEmpty && + selectedVendor != null && + selectedHub != null && + positions.isNotEmpty && + recurringDays.isNotEmpty && + datesValid && + positions.every( + (RecurringOrderPosition p) => + p.role.isNotEmpty && + p.count > 0 && + p.startTime.isNotEmpty && + p.endTime.isNotEmpty, + ); + } + + @override + List get props => [ + startDate, + endDate, + recurringDays, + location, + eventName, + positions, + autoSelectedDayIndex, + status, + errorMessage, + vendors, + selectedVendor, + hubs, + selectedHub, + roles, + ]; +} + +class RecurringOrderHubOption extends Equatable { + const RecurringOrderHubOption({ + required this.id, + required this.name, + required this.address, + this.placeId, + this.latitude, + this.longitude, + this.city, + this.state, + this.street, + this.country, + this.zipCode, + }); + + final String id; + final String name; + final String address; + final String? placeId; + final double? latitude; + final double? longitude; + final String? city; + final String? state; + final String? street; + final String? country; + final String? zipCode; + + @override + List get props => [ + id, + name, + address, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + ]; +} + +class RecurringOrderRoleOption extends Equatable { + const RecurringOrderRoleOption({ + required this.id, + required this.name, + required this.costPerHour, + }); + + final String id; + final String name; + final double costPerHour; + + @override + List get props => [id, name, costPerHour]; +} + +class RecurringOrderPosition extends Equatable { + const RecurringOrderPosition({ + required this.role, + required this.count, + required this.startTime, + required this.endTime, + this.lunchBreak, + }); + + final String role; + final int count; + final String startTime; + final String endTime; + final String? lunchBreak; + + RecurringOrderPosition copyWith({ + String? role, + int? count, + String? startTime, + String? endTime, + String? lunchBreak, + }) { + return RecurringOrderPosition( + role: role ?? this.role, + count: count ?? this.count, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + lunchBreak: lunchBreak ?? this.lunchBreak, + ); + } + + @override + List get props => [role, count, startTime, endTime, lunchBreak]; +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/permanent_order_page.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/permanent_order_page.dart index 9986095b..cdcc26e3 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/permanent_order_page.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/permanent_order_page.dart @@ -1,40 +1,19 @@ -import 'package:core_localization/core_localization.dart'; -import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; +import '../blocs/permanent_order_bloc.dart'; +import '../widgets/permanent_order/permanent_order_view.dart'; -/// Permanent Order Page - Long-term staffing placement. -/// Placeholder for future implementation. +/// Page for creating a permanent staffing order. class PermanentOrderPage extends StatelessWidget { /// Creates a [PermanentOrderPage]. const PermanentOrderPage({super.key}); @override Widget build(BuildContext context) { - final TranslationsClientCreateOrderPermanentEn labels = - t.client_create_order.permanent; - - return Scaffold( - appBar: UiAppBar( - title: labels.title, - onLeadingPressed: () => Modular.to.navigate(ClientPaths.createOrder), - ), - body: Center( - child: Padding( - padding: const EdgeInsets.all(UiConstants.space6), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - labels.subtitle, - style: UiTypography.body1r.textSecondary, - textAlign: TextAlign.center, - ), - ], - ), - ), - ), + return BlocProvider( + create: (BuildContext context) => Modular.get(), + child: const PermanentOrderView(), ); } } diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/recurring_order_page.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/recurring_order_page.dart index a649ea9b..009c4d64 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/recurring_order_page.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/recurring_order_page.dart @@ -1,40 +1,19 @@ -import 'package:core_localization/core_localization.dart'; -import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; +import '../blocs/recurring_order_bloc.dart'; +import '../widgets/recurring_order/recurring_order_view.dart'; -/// Recurring Order Page - Ongoing weekly/monthly coverage. -/// Placeholder for future implementation. +/// Page for creating a recurring staffing order. class RecurringOrderPage extends StatelessWidget { /// Creates a [RecurringOrderPage]. const RecurringOrderPage({super.key}); @override Widget build(BuildContext context) { - final TranslationsClientCreateOrderRecurringEn labels = - t.client_create_order.recurring; - - return Scaffold( - appBar: UiAppBar( - title: labels.title, - onLeadingPressed: () => Modular.to.toClientHome(), - ), - body: Center( - child: Padding( - padding: const EdgeInsets.all(UiConstants.space6), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - labels.subtitle, - style: UiTypography.body1r.textSecondary, - textAlign: TextAlign.center, - ), - ], - ), - ), - ), + return BlocProvider( + create: (BuildContext context) => Modular.get(), + child: const RecurringOrderView(), ); } } diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_date_picker.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_date_picker.dart new file mode 100644 index 00000000..7fe41016 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_date_picker.dart @@ -0,0 +1,74 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +/// A date picker field for the permanent order form. +class PermanentOrderDatePicker extends StatefulWidget { + /// Creates a [PermanentOrderDatePicker]. + const PermanentOrderDatePicker({ + required this.label, + required this.value, + required this.onChanged, + super.key, + }); + + /// The label text to display above the field. + final String label; + + /// The currently selected date. + final DateTime value; + + /// Callback when a new date is selected. + final ValueChanged onChanged; + + @override + State createState() => + _PermanentOrderDatePickerState(); +} + +class _PermanentOrderDatePickerState extends State { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController( + text: DateFormat('yyyy-MM-dd').format(widget.value), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(PermanentOrderDatePicker oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.value != oldWidget.value) { + _controller.text = DateFormat('yyyy-MM-dd').format(widget.value); + } + } + + @override + Widget build(BuildContext context) { + return UiTextField( + label: widget.label, + controller: _controller, + readOnly: true, + prefixIcon: UiIcons.calendar, + onTap: () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: widget.value, + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) { + widget.onChanged(picked); + } + }, + ); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_event_name_input.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_event_name_input.dart new file mode 100644 index 00000000..4eb0baa4 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_event_name_input.dart @@ -0,0 +1,56 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A text input for the order name in the permanent order form. +class PermanentOrderEventNameInput extends StatefulWidget { + const PermanentOrderEventNameInput({ + required this.label, + required this.value, + required this.onChanged, + super.key, + }); + + final String label; + final String value; + final ValueChanged onChanged; + + @override + State createState() => + _PermanentOrderEventNameInputState(); +} + +class _PermanentOrderEventNameInputState + extends State { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.value); + } + + @override + void didUpdateWidget(PermanentOrderEventNameInput oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.value != _controller.text) { + _controller.text = widget.value; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return UiTextField( + label: widget.label, + controller: _controller, + onChanged: widget.onChanged, + hintText: 'Order name', + prefixIcon: UiIcons.briefcase, + ); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_header.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_header.dart new file mode 100644 index 00000000..8943f5f1 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_header.dart @@ -0,0 +1,71 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A header widget for the permanent order flow with a colored background. +class PermanentOrderHeader extends StatelessWidget { + /// Creates a [PermanentOrderHeader]. + const PermanentOrderHeader({ + required this.title, + required this.subtitle, + required this.onBack, + super.key, + }); + + /// The title of the page. + final String title; + + /// The subtitle or description. + final String subtitle; + + /// Callback when the back button is pressed. + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + UiConstants.space5, + bottom: UiConstants.space5, + left: UiConstants.space5, + right: UiConstants.space5, + ), + color: UiColors.primary, + child: Row( + children: [ + GestureDetector( + onTap: onBack, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withValues(alpha: 0.2), + borderRadius: UiConstants.radiusMd, + ), + child: const Icon( + UiIcons.chevronLeft, + color: UiColors.white, + size: 24, + ), + ), + ), + const SizedBox(width: UiConstants.space3), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: UiTypography.headline3m.copyWith(color: UiColors.white), + ), + Text( + subtitle, + style: UiTypography.footnote2r.copyWith( + color: UiColors.white.withValues(alpha: 0.8), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_position_card.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_position_card.dart new file mode 100644 index 00000000..eea6cb1a --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_position_card.dart @@ -0,0 +1,345 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import '../../blocs/permanent_order_state.dart'; + +/// A card widget for editing a specific position in a permanent order. +class PermanentOrderPositionCard extends StatelessWidget { + /// Creates a [PermanentOrderPositionCard]. + const PermanentOrderPositionCard({ + required this.index, + required this.position, + required this.isRemovable, + required this.onUpdated, + required this.onRemoved, + required this.positionLabel, + required this.roleLabel, + required this.workersLabel, + required this.startLabel, + required this.endLabel, + required this.lunchLabel, + required this.roles, + super.key, + }); + + /// The index of the position in the list. + final int index; + + /// The position entity data. + final PermanentOrderPosition position; + + /// Whether this position can be removed (usually if there's more than one). + final bool isRemovable; + + /// Callback when the position data is updated. + final ValueChanged onUpdated; + + /// Callback when the position is removed. + final VoidCallback onRemoved; + + /// Label for positions (e.g., "Position"). + final String positionLabel; + + /// Label for the role selection. + final String roleLabel; + + /// Label for the worker count. + final String workersLabel; + + /// Label for the start time. + final String startLabel; + + /// Label for the end time. + final String endLabel; + + /// Label for the lunch break. + final String lunchLabel; + + /// Available roles for the selected vendor. + final List roles; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '$positionLabel #${index + 1}', + style: UiTypography.footnote1m.textSecondary, + ), + if (isRemovable) + GestureDetector( + onTap: onRemoved, + child: Text( + t.client_create_order.one_time.remove, + style: UiTypography.footnote1m.copyWith( + color: UiColors.destructive, + ), + ), + ), + ], + ), + const SizedBox(height: UiConstants.space3), + + // Role (Dropdown) + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 44, + decoration: BoxDecoration( + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + hint: Text( + roleLabel, + style: UiTypography.body2r.textPlaceholder, + ), + value: position.role.isEmpty ? null : position.role, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (String? val) { + if (val != null) { + onUpdated(position.copyWith(role: val)); + } + }, + items: _buildRoleItems(), + ), + ), + ), + const SizedBox(height: UiConstants.space3), + + // Start/End/Workers Row + Row( + children: [ + // Start Time + Expanded( + child: _buildTimeInput( + context: context, + label: startLabel, + value: position.startTime, + onTap: () async { + final TimeOfDay? picked = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + ); + if (picked != null && context.mounted) { + onUpdated( + position.copyWith(startTime: picked.format(context)), + ); + } + }, + ), + ), + const SizedBox(width: UiConstants.space2), + // End Time + Expanded( + child: _buildTimeInput( + context: context, + label: endLabel, + value: position.endTime, + onTap: () async { + final TimeOfDay? picked = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + ); + if (picked != null && context.mounted) { + onUpdated( + position.copyWith(endTime: picked.format(context)), + ); + } + }, + ), + ), + const SizedBox(width: UiConstants.space2), + // Workers Count + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + workersLabel, + style: UiTypography.footnote2r.textSecondary, + ), + const SizedBox(height: UiConstants.space1), + Container( + height: 40, + decoration: BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: UiConstants.radiusSm, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + GestureDetector( + onTap: () { + if (position.count > 1) { + onUpdated( + position.copyWith(count: position.count - 1), + ); + } + }, + child: const Icon(UiIcons.minus, size: 12), + ), + Text( + '${position.count}', + style: UiTypography.body2b.textPrimary, + ), + GestureDetector( + onTap: () { + onUpdated( + position.copyWith(count: position.count + 1), + ); + }, + child: const Icon(UiIcons.add, size: 12), + ), + ], + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: UiConstants.space4), + + // Lunch Break + Text(lunchLabel, style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space1), + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 44, + decoration: BoxDecoration( + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: position.lunchBreak, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (String? val) { + if (val != null) { + onUpdated(position.copyWith(lunchBreak: val)); + } + }, + items: [ + 'NO_BREAK', + 'MIN_10', + 'MIN_15', + 'MIN_30', + 'MIN_45', + 'MIN_60', + ].map((String value) { + final String label = switch (value) { + 'NO_BREAK' => 'No Break', + 'MIN_10' => '10 min (Paid)', + 'MIN_15' => '15 min (Paid)', + 'MIN_30' => '30 min (Unpaid)', + 'MIN_45' => '45 min (Unpaid)', + 'MIN_60' => '60 min (Unpaid)', + _ => value, + }; + return DropdownMenuItem( + value: value, + child: Text( + label, + style: UiTypography.body2r.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + ], + ), + ); + } + + Widget _buildTimeInput({ + required BuildContext context, + required String label, + required String value, + required VoidCallback onTap, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space1), + GestureDetector( + onTap: onTap, + child: Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + decoration: BoxDecoration( + borderRadius: UiConstants.radiusSm, + border: Border.all(color: UiColors.border), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + value.isEmpty ? '--:--' : value, + style: UiTypography.body2r.textPrimary, + ), + const Icon( + UiIcons.clock, + size: 14, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ), + ], + ); + } + + List> _buildRoleItems() { + final List> items = roles + .map( + (PermanentOrderRoleOption role) => DropdownMenuItem( + value: role.id, + child: Text( + '${role.name} - \$${role.costPerHour.toStringAsFixed(0)}', + style: UiTypography.body2r.textPrimary, + ), + ), + ) + .toList(); + + final bool hasSelected = roles.any((PermanentOrderRoleOption role) => role.id == position.role); + if (position.role.isNotEmpty && !hasSelected) { + items.add( + DropdownMenuItem( + value: position.role, + child: Text( + position.role, + style: UiTypography.body2r.textPrimary, + ), + ), + ); + } + + return items; + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_section_header.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_section_header.dart new file mode 100644 index 00000000..21d47825 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_section_header.dart @@ -0,0 +1,52 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A header widget for sections in the permanent order form. +class PermanentOrderSectionHeader extends StatelessWidget { + /// Creates a [PermanentOrderSectionHeader]. + const PermanentOrderSectionHeader({ + required this.title, + this.actionLabel, + this.onAction, + super.key, + }); + + /// The title text for the section. + final String title; + + /// Optional label for an action button on the right. + final String? actionLabel; + + /// Callback when the action button is tapped. + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, style: UiTypography.headline4m.textPrimary), + if (actionLabel != null && onAction != null) + TextButton( + onPressed: onAction, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(UiIcons.add, size: 16, color: UiColors.primary), + const SizedBox(width: UiConstants.space2), + Text( + actionLabel!, + style: UiTypography.body2m.primary, + ), + ], + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_success_view.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_success_view.dart new file mode 100644 index 00000000..a4b72cbc --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_success_view.dart @@ -0,0 +1,104 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A view to display when a permanent order has been successfully created. +class PermanentOrderSuccessView extends StatelessWidget { + /// Creates a [PermanentOrderSuccessView]. + const PermanentOrderSuccessView({ + required this.title, + required this.message, + required this.buttonLabel, + required this.onDone, + super.key, + }); + + /// The title of the success message. + final String title; + + /// The body of the success message. + final String message; + + /// Label for the completion button. + final String buttonLabel; + + /// Callback when the completion button is tapped. + final VoidCallback onDone; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [UiColors.primary, UiColors.buttonPrimaryHover], + ), + ), + child: SafeArea( + child: Center( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: UiConstants.space10), + padding: const EdgeInsets.all(UiConstants.space8), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg * 1.5, + boxShadow: [ + BoxShadow( + color: UiColors.black.withValues(alpha: 0.2), + blurRadius: 20, + offset: const Offset(0, UiConstants.space2 + 2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: UiConstants.space16, + height: UiConstants.space16, + decoration: const BoxDecoration( + color: UiColors.accent, + shape: BoxShape.circle, + ), + child: const Center( + child: Icon( + UiIcons.check, + color: UiColors.black, + size: UiConstants.space8, + ), + ), + ), + const SizedBox(height: UiConstants.space6), + Text( + title, + style: UiTypography.headline2m.textPrimary, + textAlign: TextAlign.center, + ), + const SizedBox(height: UiConstants.space3), + Text( + message, + textAlign: TextAlign.center, + style: UiTypography.body2r.textSecondary.copyWith( + height: 1.5, + ), + ), + const SizedBox(height: UiConstants.space8), + SizedBox( + width: double.infinity, + child: UiButton.primary( + text: buttonLabel, + onPressed: onDone, + size: UiButtonSize.large, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart new file mode 100644 index 00000000..888bd150 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart @@ -0,0 +1,400 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart' show Vendor; +import '../../blocs/permanent_order_bloc.dart'; +import '../../blocs/permanent_order_event.dart'; +import '../../blocs/permanent_order_state.dart'; +import 'permanent_order_date_picker.dart'; +import 'permanent_order_event_name_input.dart'; +import 'permanent_order_header.dart'; +import 'permanent_order_position_card.dart'; +import 'permanent_order_section_header.dart'; +import 'permanent_order_success_view.dart'; + +/// The main content of the Permanent Order page. +class PermanentOrderView extends StatelessWidget { + /// Creates a [PermanentOrderView]. + const PermanentOrderView({super.key}); + + @override + Widget build(BuildContext context) { + final TranslationsClientCreateOrderPermanentEn labels = + t.client_create_order.permanent; + final TranslationsClientCreateOrderOneTimeEn oneTimeLabels = + t.client_create_order.one_time; + + return BlocConsumer( + listener: (BuildContext context, PermanentOrderState state) { + if (state.status == PermanentOrderStatus.failure && + state.errorMessage != null) { + final String message = translateErrorKey(state.errorMessage!); + UiSnackbar.show( + context, + message: message, + type: UiSnackbarType.error, + margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16), + ); + } + }, + builder: (BuildContext context, PermanentOrderState state) { + if (state.status == PermanentOrderStatus.success) { + return PermanentOrderSuccessView( + title: labels.title, + message: labels.subtitle, + buttonLabel: oneTimeLabels.back_to_orders, + onDone: () => Modular.to.pushNamedAndRemoveUntil( + ClientPaths.orders, + (_) => false, + arguments: { + 'initialDate': state.startDate.toIso8601String(), + }, + ), + ); + } + + if (state.vendors.isEmpty && + state.status != PermanentOrderStatus.loading) { + return Scaffold( + body: Column( + children: [ + PermanentOrderHeader( + title: labels.title, + subtitle: labels.subtitle, + onBack: () => Modular.to.navigate(ClientPaths.createOrder), + ), + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + UiIcons.search, + size: 64, + color: UiColors.iconInactive, + ), + const SizedBox(height: UiConstants.space4), + Text( + 'No Vendors Available', + style: UiTypography.headline3m.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + Text( + 'There are no staffing vendors associated with your account.', + style: UiTypography.body2r.textSecondary, + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ], + ), + ); + } + + return Scaffold( + body: Column( + children: [ + PermanentOrderHeader( + title: labels.title, + subtitle: labels.subtitle, + onBack: () => Modular.to.navigate(ClientPaths.createOrder), + ), + Expanded( + child: Stack( + children: [ + _PermanentOrderForm(state: state), + if (state.status == PermanentOrderStatus.loading) + const Center(child: CircularProgressIndicator()), + ], + ), + ), + _BottomActionButton( + label: state.status == PermanentOrderStatus.loading + ? oneTimeLabels.creating + : oneTimeLabels.create_order, + isLoading: state.status == PermanentOrderStatus.loading, + onPressed: state.isValid + ? () => BlocProvider.of( + context, + ).add(const PermanentOrderSubmitted()) + : null, + ), + ], + ), + ); + }, + ); + } +} + +class _PermanentOrderForm extends StatelessWidget { + const _PermanentOrderForm({required this.state}); + final PermanentOrderState state; + + @override + Widget build(BuildContext context) { + final TranslationsClientCreateOrderPermanentEn labels = + t.client_create_order.permanent; + final TranslationsClientCreateOrderOneTimeEn oneTimeLabels = + t.client_create_order.one_time; + + return ListView( + padding: const EdgeInsets.all(UiConstants.space5), + children: [ + Text( + labels.title, + style: UiTypography.headline3m.textPrimary, + ), + const SizedBox(height: UiConstants.space4), + + PermanentOrderEventNameInput( + label: 'ORDER NAME', + value: state.eventName, + onChanged: (String value) => BlocProvider.of( + context, + ).add(PermanentOrderEventNameChanged(value)), + ), + const SizedBox(height: UiConstants.space4), + + // Vendor Selection + Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 48, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: state.selectedVendor, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (Vendor? vendor) { + if (vendor != null) { + BlocProvider.of( + context, + ).add(PermanentOrderVendorChanged(vendor)); + } + }, + items: state.vendors.map((Vendor vendor) { + return DropdownMenuItem( + value: vendor, + child: Text( + vendor.name, + style: UiTypography.body2m.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space4), + + PermanentOrderDatePicker( + label: 'Start Date', + value: state.startDate, + onChanged: (DateTime date) => BlocProvider.of( + context, + ).add(PermanentOrderStartDateChanged(date)), + ), + const SizedBox(height: UiConstants.space4), + + Text('Permanent Days', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + _PermanentDaysSelector( + selectedDays: state.permanentDays, + onToggle: (int dayIndex) => BlocProvider.of( + context, + ).add(PermanentOrderDayToggled(dayIndex)), + ), + const SizedBox(height: UiConstants.space4), + + Text('HUB', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 48, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: state.selectedHub, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (PermanentOrderHubOption? hub) { + if (hub != null) { + BlocProvider.of( + context, + ).add(PermanentOrderHubChanged(hub)); + } + }, + items: state.hubs.map((PermanentOrderHubOption hub) { + return DropdownMenuItem( + value: hub, + child: Text( + hub.name, + style: UiTypography.body2m.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space6), + + PermanentOrderSectionHeader( + title: oneTimeLabels.positions_title, + actionLabel: oneTimeLabels.add_position, + onAction: () => BlocProvider.of( + context, + ).add(const PermanentOrderPositionAdded()), + ), + const SizedBox(height: UiConstants.space3), + + // Positions List + ...state.positions.asMap().entries.map(( + MapEntry entry, + ) { + final int index = entry.key; + final PermanentOrderPosition position = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: PermanentOrderPositionCard( + index: index, + position: position, + isRemovable: state.positions.length > 1, + positionLabel: oneTimeLabels.positions_title, + roleLabel: oneTimeLabels.select_role, + workersLabel: oneTimeLabels.workers_label, + startLabel: oneTimeLabels.start_label, + endLabel: oneTimeLabels.end_label, + lunchLabel: oneTimeLabels.lunch_break_label, + roles: state.roles, + onUpdated: (PermanentOrderPosition updated) { + BlocProvider.of( + context, + ).add(PermanentOrderPositionUpdated(index, updated)); + }, + onRemoved: () { + BlocProvider.of( + context, + ).add(PermanentOrderPositionRemoved(index)); + }, + ), + ); + }), + ], + ); + } +} + +class _PermanentDaysSelector extends StatelessWidget { + const _PermanentDaysSelector({ + required this.selectedDays, + required this.onToggle, + }); + + final List selectedDays; + final ValueChanged onToggle; + + @override + Widget build(BuildContext context) { + const List labelsShort = [ + 'S', + 'M', + 'T', + 'W', + 'T', + 'F', + 'S', + ]; + const List labelsLong = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + return Wrap( + spacing: UiConstants.space2, + children: List.generate(labelsShort.length, (int index) { + final bool isSelected = selectedDays.contains(labelsLong[index]); + return GestureDetector( + onTap: () => onToggle(index), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: isSelected ? UiColors.primary : UiColors.white, + shape: BoxShape.circle, + border: Border.all(color: UiColors.border), + ), + alignment: Alignment.center, + child: Text( + labelsShort[index], + style: UiTypography.body2m.copyWith( + color: isSelected ? UiColors.white : UiColors.textSecondary, + ), + ), + ), + ); + }), + ); + } +} + +class _BottomActionButton extends StatelessWidget { + const _BottomActionButton({ + required this.label, + required this.onPressed, + this.isLoading = false, + }); + final String label; + final VoidCallback? onPressed; + final bool isLoading; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.only( + left: UiConstants.space5, + right: UiConstants.space5, + top: UiConstants.space5, + bottom: MediaQuery.of(context).padding.bottom + UiConstants.space5, + ), + decoration: const BoxDecoration( + color: UiColors.white, + border: Border(top: BorderSide(color: UiColors.border)), + ), + child: SizedBox( + width: double.infinity, + child: UiButton.primary( + text: label, + onPressed: isLoading ? null : onPressed, + size: UiButtonSize.large, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_date_picker.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_date_picker.dart new file mode 100644 index 00000000..f9b7df68 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_date_picker.dart @@ -0,0 +1,74 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +/// A date picker field for the recurring order form. +class RecurringOrderDatePicker extends StatefulWidget { + /// Creates a [RecurringOrderDatePicker]. + const RecurringOrderDatePicker({ + required this.label, + required this.value, + required this.onChanged, + super.key, + }); + + /// The label text to display above the field. + final String label; + + /// The currently selected date. + final DateTime value; + + /// Callback when a new date is selected. + final ValueChanged onChanged; + + @override + State createState() => + _RecurringOrderDatePickerState(); +} + +class _RecurringOrderDatePickerState extends State { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController( + text: DateFormat('yyyy-MM-dd').format(widget.value), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(RecurringOrderDatePicker oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.value != oldWidget.value) { + _controller.text = DateFormat('yyyy-MM-dd').format(widget.value); + } + } + + @override + Widget build(BuildContext context) { + return UiTextField( + label: widget.label, + controller: _controller, + readOnly: true, + prefixIcon: UiIcons.calendar, + onTap: () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: widget.value, + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) { + widget.onChanged(picked); + } + }, + ); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_event_name_input.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_event_name_input.dart new file mode 100644 index 00000000..22d7cae9 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_event_name_input.dart @@ -0,0 +1,56 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A text input for the order name in the recurring order form. +class RecurringOrderEventNameInput extends StatefulWidget { + const RecurringOrderEventNameInput({ + required this.label, + required this.value, + required this.onChanged, + super.key, + }); + + final String label; + final String value; + final ValueChanged onChanged; + + @override + State createState() => + _RecurringOrderEventNameInputState(); +} + +class _RecurringOrderEventNameInputState + extends State { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.value); + } + + @override + void didUpdateWidget(RecurringOrderEventNameInput oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.value != _controller.text) { + _controller.text = widget.value; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return UiTextField( + label: widget.label, + controller: _controller, + onChanged: widget.onChanged, + hintText: 'Order name', + prefixIcon: UiIcons.briefcase, + ); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_header.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_header.dart new file mode 100644 index 00000000..5913b205 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_header.dart @@ -0,0 +1,71 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A header widget for the recurring order flow with a colored background. +class RecurringOrderHeader extends StatelessWidget { + /// Creates a [RecurringOrderHeader]. + const RecurringOrderHeader({ + required this.title, + required this.subtitle, + required this.onBack, + super.key, + }); + + /// The title of the page. + final String title; + + /// The subtitle or description. + final String subtitle; + + /// Callback when the back button is pressed. + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + UiConstants.space5, + bottom: UiConstants.space5, + left: UiConstants.space5, + right: UiConstants.space5, + ), + color: UiColors.primary, + child: Row( + children: [ + GestureDetector( + onTap: onBack, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withValues(alpha: 0.2), + borderRadius: UiConstants.radiusMd, + ), + child: const Icon( + UiIcons.chevronLeft, + color: UiColors.white, + size: 24, + ), + ), + ), + const SizedBox(width: UiConstants.space3), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: UiTypography.headline3m.copyWith(color: UiColors.white), + ), + Text( + subtitle, + style: UiTypography.footnote2r.copyWith( + color: UiColors.white.withValues(alpha: 0.8), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_position_card.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_position_card.dart new file mode 100644 index 00000000..f6b94670 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_position_card.dart @@ -0,0 +1,345 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import '../../blocs/recurring_order_state.dart'; + +/// A card widget for editing a specific position in a recurring order. +class RecurringOrderPositionCard extends StatelessWidget { + /// Creates a [RecurringOrderPositionCard]. + const RecurringOrderPositionCard({ + required this.index, + required this.position, + required this.isRemovable, + required this.onUpdated, + required this.onRemoved, + required this.positionLabel, + required this.roleLabel, + required this.workersLabel, + required this.startLabel, + required this.endLabel, + required this.lunchLabel, + required this.roles, + super.key, + }); + + /// The index of the position in the list. + final int index; + + /// The position entity data. + final RecurringOrderPosition position; + + /// Whether this position can be removed (usually if there's more than one). + final bool isRemovable; + + /// Callback when the position data is updated. + final ValueChanged onUpdated; + + /// Callback when the position is removed. + final VoidCallback onRemoved; + + /// Label for positions (e.g., "Position"). + final String positionLabel; + + /// Label for the role selection. + final String roleLabel; + + /// Label for the worker count. + final String workersLabel; + + /// Label for the start time. + final String startLabel; + + /// Label for the end time. + final String endLabel; + + /// Label for the lunch break. + final String lunchLabel; + + /// Available roles for the selected vendor. + final List roles; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '$positionLabel #${index + 1}', + style: UiTypography.footnote1m.textSecondary, + ), + if (isRemovable) + GestureDetector( + onTap: onRemoved, + child: Text( + t.client_create_order.one_time.remove, + style: UiTypography.footnote1m.copyWith( + color: UiColors.destructive, + ), + ), + ), + ], + ), + const SizedBox(height: UiConstants.space3), + + // Role (Dropdown) + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 44, + decoration: BoxDecoration( + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + hint: Text( + roleLabel, + style: UiTypography.body2r.textPlaceholder, + ), + value: position.role.isEmpty ? null : position.role, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (String? val) { + if (val != null) { + onUpdated(position.copyWith(role: val)); + } + }, + items: _buildRoleItems(), + ), + ), + ), + const SizedBox(height: UiConstants.space3), + + // Start/End/Workers Row + Row( + children: [ + // Start Time + Expanded( + child: _buildTimeInput( + context: context, + label: startLabel, + value: position.startTime, + onTap: () async { + final TimeOfDay? picked = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + ); + if (picked != null && context.mounted) { + onUpdated( + position.copyWith(startTime: picked.format(context)), + ); + } + }, + ), + ), + const SizedBox(width: UiConstants.space2), + // End Time + Expanded( + child: _buildTimeInput( + context: context, + label: endLabel, + value: position.endTime, + onTap: () async { + final TimeOfDay? picked = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + ); + if (picked != null && context.mounted) { + onUpdated( + position.copyWith(endTime: picked.format(context)), + ); + } + }, + ), + ), + const SizedBox(width: UiConstants.space2), + // Workers Count + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + workersLabel, + style: UiTypography.footnote2r.textSecondary, + ), + const SizedBox(height: UiConstants.space1), + Container( + height: 40, + decoration: BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: UiConstants.radiusSm, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + GestureDetector( + onTap: () { + if (position.count > 1) { + onUpdated( + position.copyWith(count: position.count - 1), + ); + } + }, + child: const Icon(UiIcons.minus, size: 12), + ), + Text( + '${position.count}', + style: UiTypography.body2b.textPrimary, + ), + GestureDetector( + onTap: () { + onUpdated( + position.copyWith(count: position.count + 1), + ); + }, + child: const Icon(UiIcons.add, size: 12), + ), + ], + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: UiConstants.space4), + + // Lunch Break + Text(lunchLabel, style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space1), + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 44, + decoration: BoxDecoration( + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: position.lunchBreak, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (String? val) { + if (val != null) { + onUpdated(position.copyWith(lunchBreak: val)); + } + }, + items: [ + 'NO_BREAK', + 'MIN_10', + 'MIN_15', + 'MIN_30', + 'MIN_45', + 'MIN_60', + ].map((String value) { + final String label = switch (value) { + 'NO_BREAK' => 'No Break', + 'MIN_10' => '10 min (Paid)', + 'MIN_15' => '15 min (Paid)', + 'MIN_30' => '30 min (Unpaid)', + 'MIN_45' => '45 min (Unpaid)', + 'MIN_60' => '60 min (Unpaid)', + _ => value, + }; + return DropdownMenuItem( + value: value, + child: Text( + label, + style: UiTypography.body2r.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + ], + ), + ); + } + + Widget _buildTimeInput({ + required BuildContext context, + required String label, + required String value, + required VoidCallback onTap, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space1), + GestureDetector( + onTap: onTap, + child: Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + decoration: BoxDecoration( + borderRadius: UiConstants.radiusSm, + border: Border.all(color: UiColors.border), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + value.isEmpty ? '--:--' : value, + style: UiTypography.body2r.textPrimary, + ), + const Icon( + UiIcons.clock, + size: 14, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ), + ], + ); + } + + List> _buildRoleItems() { + final List> items = roles + .map( + (RecurringOrderRoleOption role) => DropdownMenuItem( + value: role.id, + child: Text( + '${role.name} - \$${role.costPerHour.toStringAsFixed(0)}', + style: UiTypography.body2r.textPrimary, + ), + ), + ) + .toList(); + + final bool hasSelected = roles.any((RecurringOrderRoleOption role) => role.id == position.role); + if (position.role.isNotEmpty && !hasSelected) { + items.add( + DropdownMenuItem( + value: position.role, + child: Text( + position.role, + style: UiTypography.body2r.textPrimary, + ), + ), + ); + } + + return items; + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_section_header.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_section_header.dart new file mode 100644 index 00000000..85326cb6 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_section_header.dart @@ -0,0 +1,52 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A header widget for sections in the recurring order form. +class RecurringOrderSectionHeader extends StatelessWidget { + /// Creates a [RecurringOrderSectionHeader]. + const RecurringOrderSectionHeader({ + required this.title, + this.actionLabel, + this.onAction, + super.key, + }); + + /// The title text for the section. + final String title; + + /// Optional label for an action button on the right. + final String? actionLabel; + + /// Callback when the action button is tapped. + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, style: UiTypography.headline4m.textPrimary), + if (actionLabel != null && onAction != null) + TextButton( + onPressed: onAction, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(UiIcons.add, size: 16, color: UiColors.primary), + const SizedBox(width: UiConstants.space2), + Text( + actionLabel!, + style: UiTypography.body2m.primary, + ), + ], + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_success_view.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_success_view.dart new file mode 100644 index 00000000..3739c5ad --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_success_view.dart @@ -0,0 +1,104 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A view to display when a recurring order has been successfully created. +class RecurringOrderSuccessView extends StatelessWidget { + /// Creates a [RecurringOrderSuccessView]. + const RecurringOrderSuccessView({ + required this.title, + required this.message, + required this.buttonLabel, + required this.onDone, + super.key, + }); + + /// The title of the success message. + final String title; + + /// The body of the success message. + final String message; + + /// Label for the completion button. + final String buttonLabel; + + /// Callback when the completion button is tapped. + final VoidCallback onDone; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [UiColors.primary, UiColors.buttonPrimaryHover], + ), + ), + child: SafeArea( + child: Center( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: UiConstants.space10), + padding: const EdgeInsets.all(UiConstants.space8), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg * 1.5, + boxShadow: [ + BoxShadow( + color: UiColors.black.withValues(alpha: 0.2), + blurRadius: 20, + offset: const Offset(0, UiConstants.space2 + 2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: UiConstants.space16, + height: UiConstants.space16, + decoration: const BoxDecoration( + color: UiColors.accent, + shape: BoxShape.circle, + ), + child: const Center( + child: Icon( + UiIcons.check, + color: UiColors.black, + size: UiConstants.space8, + ), + ), + ), + const SizedBox(height: UiConstants.space6), + Text( + title, + style: UiTypography.headline2m.textPrimary, + textAlign: TextAlign.center, + ), + const SizedBox(height: UiConstants.space3), + Text( + message, + textAlign: TextAlign.center, + style: UiTypography.body2r.textSecondary.copyWith( + height: 1.5, + ), + ), + const SizedBox(height: UiConstants.space8), + SizedBox( + width: double.infinity, + child: UiButton.primary( + text: buttonLabel, + onPressed: onDone, + size: UiButtonSize.large, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart new file mode 100644 index 00000000..89a20519 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart @@ -0,0 +1,411 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:krow_domain/krow_domain.dart' show Vendor; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import '../../blocs/recurring_order_bloc.dart'; +import '../../blocs/recurring_order_event.dart'; +import '../../blocs/recurring_order_state.dart'; +import 'recurring_order_date_picker.dart'; +import 'recurring_order_event_name_input.dart'; +import 'recurring_order_header.dart'; +import 'recurring_order_position_card.dart'; +import 'recurring_order_section_header.dart'; +import 'recurring_order_success_view.dart'; + +/// The main content of the Recurring Order page. +class RecurringOrderView extends StatelessWidget { + /// Creates a [RecurringOrderView]. + const RecurringOrderView({super.key}); + + @override + Widget build(BuildContext context) { + final TranslationsClientCreateOrderRecurringEn labels = + t.client_create_order.recurring; + final TranslationsClientCreateOrderOneTimeEn oneTimeLabels = + t.client_create_order.one_time; + + return BlocConsumer( + listener: (BuildContext context, RecurringOrderState state) { + if (state.status == RecurringOrderStatus.failure && + state.errorMessage != null) { + final String message = state.errorMessage == 'placeholder' + ? labels.placeholder + : translateErrorKey(state.errorMessage!); + UiSnackbar.show( + context, + message: message, + type: UiSnackbarType.error, + margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16), + ); + } + }, + builder: (BuildContext context, RecurringOrderState state) { + if (state.status == RecurringOrderStatus.success) { + return RecurringOrderSuccessView( + title: labels.title, + message: labels.subtitle, + buttonLabel: oneTimeLabels.back_to_orders, + onDone: () => Modular.to.pushNamedAndRemoveUntil( + ClientPaths.orders, + (_) => false, + arguments: { + 'initialDate': state.startDate.toIso8601String(), + }, + ), + ); + } + + if (state.vendors.isEmpty && + state.status != RecurringOrderStatus.loading) { + return Scaffold( + body: Column( + children: [ + RecurringOrderHeader( + title: labels.title, + subtitle: labels.subtitle, + onBack: () => Modular.to.navigate(ClientPaths.createOrder), + ), + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + UiIcons.search, + size: 64, + color: UiColors.iconInactive, + ), + const SizedBox(height: UiConstants.space4), + Text( + 'No Vendors Available', + style: UiTypography.headline3m.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + Text( + 'There are no staffing vendors associated with your account.', + style: UiTypography.body2r.textSecondary, + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ], + ), + ); + } + + return Scaffold( + body: Column( + children: [ + RecurringOrderHeader( + title: labels.title, + subtitle: labels.subtitle, + onBack: () => Modular.to.navigate(ClientPaths.createOrder), + ), + Expanded( + child: Stack( + children: [ + _RecurringOrderForm(state: state), + if (state.status == RecurringOrderStatus.loading) + const Center(child: CircularProgressIndicator()), + ], + ), + ), + _BottomActionButton( + label: state.status == RecurringOrderStatus.loading + ? oneTimeLabels.creating + : oneTimeLabels.create_order, + isLoading: state.status == RecurringOrderStatus.loading, + onPressed: state.isValid + ? () => BlocProvider.of( + context, + ).add(const RecurringOrderSubmitted()) + : null, + ), + ], + ), + ); + }, + ); + } +} + +class _RecurringOrderForm extends StatelessWidget { + const _RecurringOrderForm({required this.state}); + final RecurringOrderState state; + + @override + Widget build(BuildContext context) { + final TranslationsClientCreateOrderRecurringEn labels = + t.client_create_order.recurring; + final TranslationsClientCreateOrderOneTimeEn oneTimeLabels = + t.client_create_order.one_time; + + return ListView( + padding: const EdgeInsets.all(UiConstants.space5), + children: [ + Text( + labels.title, + style: UiTypography.headline3m.textPrimary, + ), + const SizedBox(height: UiConstants.space4), + + RecurringOrderEventNameInput( + label: 'ORDER NAME', + value: state.eventName, + onChanged: (String value) => BlocProvider.of( + context, + ).add(RecurringOrderEventNameChanged(value)), + ), + const SizedBox(height: UiConstants.space4), + + // Vendor Selection + Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 48, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: state.selectedVendor, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (Vendor? vendor) { + if (vendor != null) { + BlocProvider.of( + context, + ).add(RecurringOrderVendorChanged(vendor)); + } + }, + items: state.vendors.map((Vendor vendor) { + return DropdownMenuItem( + value: vendor, + child: Text( + vendor.name, + style: UiTypography.body2m.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space4), + + RecurringOrderDatePicker( + label: 'Start Date', + value: state.startDate, + onChanged: (DateTime date) => BlocProvider.of( + context, + ).add(RecurringOrderStartDateChanged(date)), + ), + const SizedBox(height: UiConstants.space4), + + RecurringOrderDatePicker( + label: 'End Date', + value: state.endDate, + onChanged: (DateTime date) => BlocProvider.of( + context, + ).add(RecurringOrderEndDateChanged(date)), + ), + const SizedBox(height: UiConstants.space4), + + Text('Recurring Days', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + _RecurringDaysSelector( + selectedDays: state.recurringDays, + onToggle: (int dayIndex) => BlocProvider.of( + context, + ).add(RecurringOrderDayToggled(dayIndex)), + ), + const SizedBox(height: UiConstants.space4), + + Text('HUB', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 48, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: state.selectedHub, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (RecurringOrderHubOption? hub) { + if (hub != null) { + BlocProvider.of( + context, + ).add(RecurringOrderHubChanged(hub)); + } + }, + items: state.hubs.map((RecurringOrderHubOption hub) { + return DropdownMenuItem( + value: hub, + child: Text( + hub.name, + style: UiTypography.body2m.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space6), + + RecurringOrderSectionHeader( + title: oneTimeLabels.positions_title, + actionLabel: oneTimeLabels.add_position, + onAction: () => BlocProvider.of( + context, + ).add(const RecurringOrderPositionAdded()), + ), + const SizedBox(height: UiConstants.space3), + + // Positions List + ...state.positions.asMap().entries.map(( + MapEntry entry, + ) { + final int index = entry.key; + final RecurringOrderPosition position = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: RecurringOrderPositionCard( + index: index, + position: position, + isRemovable: state.positions.length > 1, + positionLabel: oneTimeLabels.positions_title, + roleLabel: oneTimeLabels.select_role, + workersLabel: oneTimeLabels.workers_label, + startLabel: oneTimeLabels.start_label, + endLabel: oneTimeLabels.end_label, + lunchLabel: oneTimeLabels.lunch_break_label, + roles: state.roles, + onUpdated: (RecurringOrderPosition updated) { + BlocProvider.of( + context, + ).add(RecurringOrderPositionUpdated(index, updated)); + }, + onRemoved: () { + BlocProvider.of( + context, + ).add(RecurringOrderPositionRemoved(index)); + }, + ), + ); + }), + ], + ); + } +} + +class _RecurringDaysSelector extends StatelessWidget { + const _RecurringDaysSelector({ + required this.selectedDays, + required this.onToggle, + }); + + final List selectedDays; + final ValueChanged onToggle; + + @override + Widget build(BuildContext context) { + const List labelsShort = [ + 'S', + 'M', + 'T', + 'W', + 'T', + 'F', + 'S', + ]; + const List labelsLong = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + return Wrap( + spacing: UiConstants.space2, + children: List.generate(labelsShort.length, (int index) { + final bool isSelected = selectedDays.contains(labelsLong[index]); + return GestureDetector( + onTap: () => onToggle(index), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: isSelected ? UiColors.primary : UiColors.white, + shape: BoxShape.circle, + border: Border.all(color: UiColors.border), + ), + alignment: Alignment.center, + child: Text( + labelsShort[index], + style: UiTypography.body2m.copyWith( + color: isSelected ? UiColors.white : UiColors.textSecondary, + ), + ), + ), + ); + }), + ); + } +} + +class _BottomActionButton extends StatelessWidget { + const _BottomActionButton({ + required this.label, + required this.onPressed, + this.isLoading = false, + }); + final String label; + final VoidCallback? onPressed; + final bool isLoading; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.only( + left: UiConstants.space5, + right: UiConstants.space5, + top: UiConstants.space5, + bottom: MediaQuery.of(context).padding.bottom + UiConstants.space5, + ), + decoration: const BoxDecoration( + color: UiColors.white, + border: Border(top: BorderSide(color: UiColors.border)), + ), + child: SizedBox( + width: double.infinity, + child: UiButton.primary( + text: label, + onPressed: isLoading ? null : onPressed, + size: UiButtonSize.large, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart b/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart index 61de301e..980f7e0b 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart @@ -105,7 +105,7 @@ class HomeRepositoryImpl address: staff.addres, avatar: staff.photoUrl, ), - ownerId: session?.ownerId, + ownerId: staff.ownerId, ); StaffSessionStore.instance.setSession(updatedSession); diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart index 7de014d6..906d45f1 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart @@ -49,13 +49,17 @@ class WorkerHomePage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ BlocBuilder( - buildWhen: (previous, current) => previous.staffName != current.staffName, + buildWhen: (previous, current) => + previous.staffName != current.staffName, builder: (context, state) { return HomeHeader(userName: state.staffName); }, ), Padding( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4), + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space4, + ), child: Column( children: [ BlocBuilder( @@ -67,7 +71,7 @@ class WorkerHomePage extends StatelessWidget { return PlaceholderBanner( title: bannersI18n.complete_profile_title, subtitle: bannersI18n.complete_profile_subtitle, - bg: UiColors.bgHighlight, + bg: UiColors.primaryInverse, accent: UiColors.primary, onTap: () { Modular.to.toProfile(); @@ -135,7 +139,8 @@ class WorkerHomePage extends StatelessWidget { EmptyStateWidget( message: emptyI18n.no_shifts_today, actionLink: emptyI18n.find_shifts_cta, - onAction: () => Modular.to.toShifts(initialTab: 'find'), + onAction: () => + Modular.to.toShifts(initialTab: 'find'), ) else Column( @@ -183,9 +188,7 @@ class WorkerHomePage extends StatelessWidget { const SizedBox(height: UiConstants.space6), // Recommended Shifts - SectionHeader( - title: sectionsI18n.recommended_for_you, - ), + SectionHeader(title: sectionsI18n.recommended_for_you), BlocBuilder( builder: (context, state) { if (state.recommendedShifts.isEmpty) { @@ -201,7 +204,8 @@ class WorkerHomePage extends StatelessWidget { clipBehavior: Clip.none, itemBuilder: (context, index) => Padding( padding: const EdgeInsets.only( - right: UiConstants.space3), + right: UiConstants.space3, + ), child: RecommendedShiftCard( shift: state.recommendedShifts[index], ), diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/placeholder_banner.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/placeholder_banner.dart index 1d648bc4..af821f42 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/placeholder_banner.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/placeholder_banner.dart @@ -1,24 +1,33 @@ import 'package:flutter/material.dart'; - import 'package:design_system/design_system.dart'; - /// Banner widget for placeholder actions, using design system tokens. class PlaceholderBanner extends StatelessWidget { /// Banner title final String title; + /// Banner subtitle final String subtitle; + /// Banner background color final Color bg; + /// Banner accent color final Color accent; + /// Optional tap callback final VoidCallback? onTap; /// Creates a [PlaceholderBanner]. - const PlaceholderBanner({super.key, required this.title, required this.subtitle, required this.bg, required this.accent, this.onTap}); + const PlaceholderBanner({ + super.key, + required this.title, + required this.subtitle, + required this.bg, + required this.accent, + this.onTap, + }); @override Widget build(BuildContext context) { @@ -29,7 +38,7 @@ class PlaceholderBanner extends StatelessWidget { decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all(color: accent.withValues(alpha: 0.3)), + border: Border.all(color: accent, width: 1), ), child: Row( children: [ @@ -41,7 +50,11 @@ class PlaceholderBanner extends StatelessWidget { color: UiColors.bgBanner, shape: BoxShape.circle, ), - child: Icon(UiIcons.sparkles, color: accent, size: UiConstants.space5), + child: Icon( + UiIcons.sparkles, + color: accent, + size: UiConstants.space5, + ), ), const SizedBox(width: UiConstants.space3), Expanded( @@ -50,12 +63,9 @@ class PlaceholderBanner extends StatelessWidget { children: [ Text( title, - style: UiTypography.body1b, - ), - Text( - subtitle, - style: UiTypography.body3r.copyWith(color: UiColors.mutedForeground), + style: UiTypography.body1b.copyWith(color: accent), ), + Text(subtitle, style: UiTypography.body3r.textSecondary), ], ), ), diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart index f16beaec..96b98016 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart @@ -8,14 +8,11 @@ import 'package:krow_domain/krow_domain.dart'; import '../blocs/profile_cubit.dart'; import '../blocs/profile_state.dart'; -import '../widgets/language_selector_bottom_sheet.dart'; import '../widgets/logout_button.dart'; import '../widgets/profile_header.dart'; -import '../widgets/profile_menu_grid.dart'; -import '../widgets/profile_menu_item.dart'; import '../widgets/reliability_score_bar.dart'; import '../widgets/reliability_stats_card.dart'; -import '../widgets/section_title.dart'; +import '../widgets/sections/index.dart'; /// The main Staff Profile page. /// @@ -49,7 +46,6 @@ class StaffProfilePage extends StatelessWidget { @override Widget build(BuildContext context) { - final i18n = Translations.of(context).staff.profile; final ProfileCubit cubit = Modular.get(); // Load profile data on first build @@ -60,7 +56,7 @@ class StaffProfilePage extends StatelessWidget { return Scaffold( body: BlocConsumer( bloc: cubit, - listener: (context, state) { + listener: (BuildContext context, ProfileState state) { if (state.status == ProfileStatus.signedOut) { Modular.to.toGetStartedPage(); } else if (state.status == ProfileStatus.error && @@ -72,7 +68,7 @@ class StaffProfilePage extends StatelessWidget { ); } }, - builder: (context, state) { + builder: (BuildContext context, ProfileState state) { // Show loading spinner if status is loading if (state.status == ProfileStatus.loading) { return const Center(child: CircularProgressIndicator()); @@ -95,7 +91,7 @@ class StaffProfilePage extends StatelessWidget { ); } - final profile = state.profile; + final Staff? profile = state.profile; if (profile == null) { return const Center(child: CircularProgressIndicator()); } @@ -103,7 +99,7 @@ class StaffProfilePage extends StatelessWidget { return SingleChildScrollView( padding: const EdgeInsets.only(bottom: UiConstants.space16), child: Column( - children: [ + children: [ ProfileHeader( fullName: profile.name, level: _mapStatusToLevel(profile.status), @@ -117,7 +113,7 @@ class StaffProfilePage extends StatelessWidget { horizontal: UiConstants.space5, ), child: Column( - children: [ + children: [ ReliabilityStatsCard( totalShifts: profile.totalShifts, averageRating: profile.averageRating, @@ -130,86 +126,15 @@ class StaffProfilePage extends StatelessWidget { reliabilityScore: profile.reliabilityScore, ), const SizedBox(height: UiConstants.space6), - SectionTitle(i18n.sections.onboarding), - ProfileMenuGrid( - crossAxisCount: 3, - - children: [ - ProfileMenuItem( - icon: UiIcons.user, - label: i18n.menu_items.personal_info, - onTap: () => Modular.to.toPersonalInfo(), - ), - ProfileMenuItem( - icon: UiIcons.phone, - label: i18n.menu_items.emergency_contact, - onTap: () => Modular.to.toEmergencyContact(), - ), - ProfileMenuItem( - icon: UiIcons.briefcase, - label: i18n.menu_items.experience, - onTap: () => Modular.to.toExperience(), - ), - ], - ), + const OnboardingSection(), const SizedBox(height: UiConstants.space6), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionTitle(i18n.sections.compliance), - ProfileMenuGrid( - crossAxisCount: 3, - children: [ - ProfileMenuItem( - icon: UiIcons.file, - label: i18n.menu_items.tax_forms, - onTap: () => Modular.to.toTaxForms(), - ), - ], - ), - ], - ), + const ComplianceSection(), const SizedBox(height: UiConstants.space6), - SectionTitle(i18n.sections.finance), - ProfileMenuGrid( - crossAxisCount: 3, - children: [ - ProfileMenuItem( - icon: UiIcons.building, - label: i18n.menu_items.bank_account, - onTap: () => Modular.to.toBankAccount(), - ), - ProfileMenuItem( - icon: UiIcons.creditCard, - label: i18n.menu_items.payments, - onTap: () => Modular.to.toPayments(), - ), - ProfileMenuItem( - icon: UiIcons.clock, - label: i18n.menu_items.timecard, - onTap: () => Modular.to.toTimeCard(), - ), - ], - ), + const FinanceSection(), const SizedBox(height: UiConstants.space6), - SectionTitle( - i18n.header.title.contains("Perfil") ? "Ajustes" : "Settings", - ), - ProfileMenuGrid( - crossAxisCount: 3, - children: [ - ProfileMenuItem( - icon: UiIcons.globe, - label: i18n.header.title.contains("Perfil") ? "Idioma" : "Language", - onTap: () { - showModalBottomSheet( - context: context, - builder: (context) => const LanguageSelectorBottomSheet(), - ); - }, - ), - ], - ), + const SupportSection(), + const SizedBox(height: UiConstants.space6), + const SettingsSection(), const SizedBox(height: UiConstants.space6), LogoutButton( onTap: () => _onSignOut(cubit, state), diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/compliance_section.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/compliance_section.dart new file mode 100644 index 00000000..a3a5211a --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/compliance_section.dart @@ -0,0 +1,39 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; + +import '../profile_menu_grid.dart'; +import '../profile_menu_item.dart'; +import '../section_title.dart'; + +/// Widget displaying the compliance section of the staff profile. +/// +/// This section contains menu items for tax forms and other compliance-related documents. +class ComplianceSection extends StatelessWidget { + /// Creates a [ComplianceSection]. + const ComplianceSection({super.key}); + + @override + Widget build(BuildContext context) { + final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionTitle(i18n.sections.compliance), + ProfileMenuGrid( + crossAxisCount: 3, + children: [ + ProfileMenuItem( + icon: UiIcons.file, + label: i18n.menu_items.tax_forms, + onTap: () => Modular.to.toTaxForms(), + ), + ], + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/finance_section.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/finance_section.dart new file mode 100644 index 00000000..73db7355 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/finance_section.dart @@ -0,0 +1,48 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; + +import '../profile_menu_grid.dart'; +import '../profile_menu_item.dart'; +import '../section_title.dart'; + +/// Widget displaying the finance section of the staff profile. +/// +/// This section contains menu items for bank account, payments, and timecard information. +class FinanceSection extends StatelessWidget { + /// Creates a [FinanceSection]. + const FinanceSection({super.key}); + + @override + Widget build(BuildContext context) { + final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile; + + return Column( + children: [ + SectionTitle(i18n.sections.finance), + ProfileMenuGrid( + crossAxisCount: 3, + children: [ + ProfileMenuItem( + icon: UiIcons.building, + label: i18n.menu_items.bank_account, + onTap: () => Modular.to.toBankAccount(), + ), + ProfileMenuItem( + icon: UiIcons.creditCard, + label: i18n.menu_items.payments, + onTap: () => Modular.to.toPayments(), + ), + ProfileMenuItem( + icon: UiIcons.clock, + label: i18n.menu_items.timecard, + onTap: () => Modular.to.toTimeCard(), + ), + ], + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/index.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/index.dart new file mode 100644 index 00000000..967a4dac --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/index.dart @@ -0,0 +1,5 @@ +export 'compliance_section.dart'; +export 'finance_section.dart'; +export 'onboarding_section.dart'; +export 'settings_section.dart'; +export 'support_section.dart'; diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/onboarding_section.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/onboarding_section.dart new file mode 100644 index 00000000..2d9201e3 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/onboarding_section.dart @@ -0,0 +1,49 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; + +import '../profile_menu_grid.dart'; +import '../profile_menu_item.dart'; +import '../section_title.dart'; + +/// Widget displaying the onboarding section of the staff profile. +/// +/// This section contains menu items for personal information, emergency contact, +/// and work experience setup. +class OnboardingSection extends StatelessWidget { + /// Creates an [OnboardingSection]. + const OnboardingSection({super.key}); + + @override + Widget build(BuildContext context) { + final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile; + + return Column( + children: [ + SectionTitle(i18n.sections.onboarding), + ProfileMenuGrid( + crossAxisCount: 3, + children: [ + ProfileMenuItem( + icon: UiIcons.user, + label: i18n.menu_items.personal_info, + onTap: () => Modular.to.toPersonalInfo(), + ), + ProfileMenuItem( + icon: UiIcons.phone, + label: i18n.menu_items.emergency_contact, + onTap: () => Modular.to.toEmergencyContact(), + ), + ProfileMenuItem( + icon: UiIcons.briefcase, + label: i18n.menu_items.experience, + onTap: () => Modular.to.toExperience(), + ), + ], + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/settings_section.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/settings_section.dart new file mode 100644 index 00000000..5fa0b4f5 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/settings_section.dart @@ -0,0 +1,47 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../language_selector_bottom_sheet.dart'; +import '../profile_menu_grid.dart'; +import '../profile_menu_item.dart'; +import '../section_title.dart'; + +/// Widget displaying the settings section of the staff profile. +/// +/// This section contains menu items for language selection. +class SettingsSection extends StatelessWidget { + /// Creates a [SettingsSection]. + const SettingsSection({super.key}); + + @override + Widget build(BuildContext context) { + final TranslationsStaffProfileEn i18n = Translations.of( + context, + ).staff.profile; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + + children: [ + SectionTitle(i18n.sections.settings), + ProfileMenuGrid( + crossAxisCount: 3, + children: [ + ProfileMenuItem( + icon: UiIcons.globe, + label: i18n.menu_items.language, + onTap: () { + showModalBottomSheet( + context: context, + builder: (BuildContext context) => + const LanguageSelectorBottomSheet(), + ); + }, + ), + ], + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/support_section.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/support_section.dart new file mode 100644 index 00000000..f547c340 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/support_section.dart @@ -0,0 +1,46 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; + +import '../profile_menu_grid.dart'; +import '../profile_menu_item.dart'; +import '../section_title.dart'; + +/// Widget displaying the support section of the staff profile. +/// +/// This section contains menu items for FAQs and privacy & security settings. +class SupportSection extends StatelessWidget { + /// Creates a [SupportSection]. + const SupportSection({super.key}); + + @override + Widget build(BuildContext context) { + final TranslationsStaffProfileEn i18n = Translations.of( + context, + ).staff.profile; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionTitle(i18n.sections.support), + ProfileMenuGrid( + crossAxisCount: 3, + children: [ + ProfileMenuItem( + icon: UiIcons.helpCircle, + label: i18n.menu_items.faqs, + onTap: () => Modular.to.toFaqs(), + ), + ProfileMenuItem( + icon: UiIcons.shield, + label: i18n.menu_items.privacy_security, + onTap: () => Modular.to.toPrivacySecurity(), + ), + ], + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/assets/faqs/faqs.json b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/assets/faqs/faqs.json new file mode 100644 index 00000000..6b726e27 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/assets/faqs/faqs.json @@ -0,0 +1,53 @@ +[ + { + "category": "Getting Started", + "questions": [ + { + "q": "How do I apply for shifts?", + "a": "Browse available shifts on the Shifts tab and tap \"Accept\" on any shift that interests you. Once confirmed, you'll receive all the details you need." + }, + { + "q": "How do I get paid?", + "a": "Payments are processed weekly via direct deposit to your linked bank account. You can view your earnings in the Payments section." + }, + { + "q": "What if I need to cancel a shift?", + "a": "You can cancel a shift up to 24 hours before it starts without penalty. Late cancellations may affect your reliability score." + } + ] + }, + { + "category": "Shifts & Work", + "questions": [ + { + "q": "How do I clock in?", + "a": "Use the Clock In feature on the home screen when you arrive at your shift. Make sure location services are enabled for verification." + }, + { + "q": "What should I wear?", + "a": "Check the shift details for dress code requirements. You can manage your wardrobe in the Attire section of your profile." + }, + { + "q": "Who do I contact if I'm running late?", + "a": "Use the \"Running Late\" feature in the app to notify the client. You can also message the shift manager directly." + } + ] + }, + { + "category": "Payments & Earnings", + "questions": [ + { + "q": "When do I get paid?", + "a": "Payments are processed every Friday for shifts completed the previous week. Funds typically arrive within 1-2 business days." + }, + { + "q": "How do I update my bank account?", + "a": "Go to Profile > Finance > Bank Account to add or update your banking information." + }, + { + "q": "Where can I find my tax documents?", + "a": "Tax documents (1099) are available in Profile > Compliance > Tax Documents by January 31st each year." + } + ] + } +] diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/data/repositories_impl/faqs_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/data/repositories_impl/faqs_repository_impl.dart new file mode 100644 index 00000000..4bcc2ccd --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/data/repositories_impl/faqs_repository_impl.dart @@ -0,0 +1,101 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; + +import '../../domain/entities/faq_category.dart'; +import '../../domain/entities/faq_item.dart'; +import '../../domain/repositories/faqs_repository_interface.dart'; + +/// Data layer implementation of FAQs repository +/// +/// Handles loading FAQs from app assets (JSON file) +class FaqsRepositoryImpl implements FaqsRepositoryInterface { + /// Private cache for FAQs to avoid reloading from assets multiple times + List? _cachedFaqs; + + @override + Future> getFaqs() async { + try { + // Return cached FAQs if available + if (_cachedFaqs != null) { + return _cachedFaqs!; + } + + // Load FAQs from JSON asset + final String faqsJson = await rootBundle.loadString( + 'packages/staff_faqs/lib/src/assets/faqs/faqs.json', + ); + + // Parse JSON + final List decoded = jsonDecode(faqsJson) as List; + + // Convert to domain entities + _cachedFaqs = decoded.map((dynamic item) { + final Map category = item as Map; + final String categoryName = category['category'] as String; + final List questionsData = + category['questions'] as List; + + final List questions = questionsData.map((dynamic q) { + final Map questionMap = q as Map; + return FaqItem( + question: questionMap['q'] as String, + answer: questionMap['a'] as String, + ); + }).toList(); + + return FaqCategory( + category: categoryName, + questions: questions, + ); + }).toList(); + + return _cachedFaqs!; + } catch (e) { + // Return empty list on error + return []; + } + } + + @override + Future> searchFaqs(String query) async { + try { + // Get all FAQs first + final List allFaqs = await getFaqs(); + + if (query.isEmpty) { + return allFaqs; + } + + final String lowerQuery = query.toLowerCase(); + + // Filter categories based on matching questions + final List filtered = allFaqs + .map((FaqCategory category) { + // Filter questions that match the query + final List matchingQuestions = + category.questions.where((FaqItem item) { + final String questionLower = item.question.toLowerCase(); + final String answerLower = item.answer.toLowerCase(); + return questionLower.contains(lowerQuery) || + answerLower.contains(lowerQuery); + }).toList(); + + // Only include category if it has matching questions + if (matchingQuestions.isNotEmpty) { + return FaqCategory( + category: category.category, + questions: matchingQuestions, + ); + } + return null; + }) + .whereType() + .toList(); + + return filtered; + } catch (e) { + return []; + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_category.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_category.dart new file mode 100644 index 00000000..b199ea3b --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_category.dart @@ -0,0 +1,20 @@ +import 'package:equatable/equatable.dart'; + +import 'faq_item.dart'; + +/// Entity representing an FAQ category with its questions +class FaqCategory extends Equatable { + /// The category name (e.g., "Getting Started", "Shifts & Work") + final String category; + + /// List of FAQ items in this category + final List questions; + + const FaqCategory({ + required this.category, + required this.questions, + }); + + @override + List get props => [category, questions]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_item.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_item.dart new file mode 100644 index 00000000..c8bb86d8 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_item.dart @@ -0,0 +1,18 @@ +import 'package:equatable/equatable.dart'; + +/// Entity representing a single FAQ question and answer +class FaqItem extends Equatable { + /// The question text + final String question; + + /// The answer text + final String answer; + + const FaqItem({ + required this.question, + required this.answer, + }); + + @override + List get props => [question, answer]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/repositories/faqs_repository_interface.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/repositories/faqs_repository_interface.dart new file mode 100644 index 00000000..887ea0d1 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/repositories/faqs_repository_interface.dart @@ -0,0 +1,11 @@ +import '../entities/faq_category.dart'; + +/// Interface for FAQs repository operations +abstract class FaqsRepositoryInterface { + /// Fetch all FAQ categories with their questions + Future> getFaqs(); + + /// Search FAQs by query string + /// Returns categories that contain matching questions + Future> searchFaqs(String query); +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/get_faqs_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/get_faqs_usecase.dart new file mode 100644 index 00000000..c4da8f89 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/get_faqs_usecase.dart @@ -0,0 +1,19 @@ +import '../entities/faq_category.dart'; +import '../repositories/faqs_repository_interface.dart'; + +/// Use case to retrieve all FAQs +class GetFaqsUseCase { + final FaqsRepositoryInterface _repository; + + GetFaqsUseCase(this._repository); + + /// Execute the use case to get all FAQ categories + Future> call() async { + try { + return await _repository.getFaqs(); + } catch (e) { + // Return empty list on error + return []; + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/search_faqs_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/search_faqs_usecase.dart new file mode 100644 index 00000000..39d36179 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/search_faqs_usecase.dart @@ -0,0 +1,27 @@ +import '../entities/faq_category.dart'; +import '../repositories/faqs_repository_interface.dart'; + +/// Parameters for search FAQs use case +class SearchFaqsParams { + /// Search query string + final String query; + + SearchFaqsParams({required this.query}); +} + +/// Use case to search FAQs by query +class SearchFaqsUseCase { + final FaqsRepositoryInterface _repository; + + SearchFaqsUseCase(this._repository); + + /// Execute the use case to search FAQs + Future> call(SearchFaqsParams params) async { + try { + return await _repository.searchFaqs(params.query); + } catch (e) { + // Return empty list on error + return []; + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_bloc.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_bloc.dart new file mode 100644 index 00000000..89c2291e --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_bloc.dart @@ -0,0 +1,76 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:equatable/equatable.dart'; + +import '../../domain/entities/faq_category.dart'; +import '../../domain/usecases/get_faqs_usecase.dart'; +import '../../domain/usecases/search_faqs_usecase.dart'; + +part 'faqs_event.dart'; +part 'faqs_state.dart'; + +/// BLoC managing FAQs state +class FaqsBloc extends Bloc { + final GetFaqsUseCase _getFaqsUseCase; + final SearchFaqsUseCase _searchFaqsUseCase; + + FaqsBloc({ + required GetFaqsUseCase getFaqsUseCase, + required SearchFaqsUseCase searchFaqsUseCase, + }) : _getFaqsUseCase = getFaqsUseCase, + _searchFaqsUseCase = searchFaqsUseCase, + super(const FaqsState()) { + on(_onFetchFaqs); + on(_onSearchFaqs); + } + + Future _onFetchFaqs( + FetchFaqsEvent event, + Emitter emit, + ) async { + emit(state.copyWith(isLoading: true, error: null)); + + try { + final List categories = await _getFaqsUseCase.call(); + emit( + state.copyWith( + isLoading: false, + categories: categories, + searchQuery: '', + ), + ); + } catch (e) { + emit( + state.copyWith( + isLoading: false, + error: 'Failed to load FAQs', + ), + ); + } + } + + Future _onSearchFaqs( + SearchFaqsEvent event, + Emitter emit, + ) async { + emit(state.copyWith(isLoading: true, error: null, searchQuery: event.query)); + + try { + final List results = await _searchFaqsUseCase.call( + SearchFaqsParams(query: event.query), + ); + emit( + state.copyWith( + isLoading: false, + categories: results, + ), + ); + } catch (e) { + emit( + state.copyWith( + isLoading: false, + error: 'Failed to search FAQs', + ), + ); + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_event.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_event.dart new file mode 100644 index 00000000..a853c239 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_event.dart @@ -0,0 +1,25 @@ +part of 'faqs_bloc.dart'; + +/// Base class for FAQs BLoC events +abstract class FaqsEvent extends Equatable { + const FaqsEvent(); + + @override + List get props => []; +} + +/// Event to fetch all FAQs +class FetchFaqsEvent extends FaqsEvent { + const FetchFaqsEvent(); +} + +/// Event to search FAQs by query +class SearchFaqsEvent extends FaqsEvent { + /// Search query string + final String query; + + const SearchFaqsEvent({required this.query}); + + @override + List get props => [query]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_state.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_state.dart new file mode 100644 index 00000000..29302c5f --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_state.dart @@ -0,0 +1,46 @@ +part of 'faqs_bloc.dart'; + +/// State for FAQs BLoC +class FaqsState extends Equatable { + /// List of FAQ categories currently displayed + final List categories; + + /// Whether FAQs are currently loading + final bool isLoading; + + /// Current search query + final String searchQuery; + + /// Error message, if any + final String? error; + + const FaqsState({ + this.categories = const [], + this.isLoading = false, + this.searchQuery = '', + this.error, + }); + + /// Create a copy with optional field overrides + FaqsState copyWith({ + List? categories, + bool? isLoading, + String? searchQuery, + String? error, + }) { + return FaqsState( + categories: categories ?? this.categories, + isLoading: isLoading ?? this.isLoading, + searchQuery: searchQuery ?? this.searchQuery, + error: error, + ); + } + + @override + List get props => [ + categories, + isLoading, + searchQuery, + error, + ]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/pages/faqs_page.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/pages/faqs_page.dart new file mode 100644 index 00000000..1c99a9ab --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/pages/faqs_page.dart @@ -0,0 +1,32 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; + +import '../blocs/faqs_bloc.dart'; +import '../widgets/faqs_widget.dart'; + +/// Page displaying frequently asked questions +class FaqsPage extends StatelessWidget { + const FaqsPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: UiAppBar( + title: t.staff_faqs.title, + showBackButton: true, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Container(color: UiColors.border, height: 1), + ), + ), + body: BlocProvider( + create: (BuildContext context) => + Modular.get()..add(const FetchFaqsEvent()), + child: const Stack(children: [FaqsWidget()]), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_widget.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_widget.dart new file mode 100644 index 00000000..bda66591 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_widget.dart @@ -0,0 +1,194 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:staff_faqs/src/presentation/blocs/faqs_bloc.dart'; + +/// Widget displaying FAQs with search functionality and accordion items +class FaqsWidget extends StatefulWidget { + const FaqsWidget({super.key}); + + @override + State createState() => _FaqsWidgetState(); +} + +class _FaqsWidgetState extends State { + late TextEditingController _searchController; + final Map _openItems = {}; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _toggleItem(String key) { + setState(() { + _openItems[key] = !(_openItems[key] ?? false); + }); + } + + void _onSearchChanged(String value) { + if (value.isEmpty) { + context.read().add(const FetchFaqsEvent()); + } else { + context.read().add(SearchFaqsEvent(query: value)); + } + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (BuildContext context, FaqsState state) { + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 100), + child: Column( + children: [ + // Search Bar + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all(color: UiColors.border), + ), + child: TextField( + controller: _searchController, + onChanged: _onSearchChanged, + decoration: InputDecoration( + hintText: t.staff_faqs.search_placeholder, + hintStyle: const TextStyle(color: UiColors.textPlaceholder), + prefixIcon: const Icon( + UiIcons.search, + color: UiColors.textSecondary, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + const SizedBox(height: 24), + + // FAQ List or Empty State + if (state.isLoading) + const Padding( + padding: EdgeInsets.symmetric(vertical: 48), + child: CircularProgressIndicator(), + ) + else if (state.categories.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Column( + children: [ + const Icon( + UiIcons.helpCircle, + size: 48, + color: UiColors.textSecondary, + ), + const SizedBox(height: 12), + Text( + t.staff_faqs.no_results, + style: const TextStyle(color: UiColors.textSecondary), + ), + ], + ), + ) + else + ...state.categories.asMap().entries.map(( + MapEntry entry, + ) { + final int catIndex = entry.key; + final dynamic categoryItem = entry.value; + final String categoryName = categoryItem.category; + final List questions = categoryItem.questions; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + categoryName, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 12), + ...questions.asMap().entries.map(( + MapEntry qEntry, + ) { + final int qIndex = qEntry.key; + final dynamic questionItem = qEntry.value; + final String key = '$catIndex-$qIndex'; + final bool isOpen = _openItems[key] ?? false; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), + border: Border.all(color: UiColors.border), + ), + child: Column( + children: [ + InkWell( + onTap: () => _toggleItem(key), + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: Text( + questionItem.question, + style: UiTypography.body1r, + ), + ), + Icon( + isOpen + ? UiIcons.chevronUp + : UiIcons.chevronDown, + color: UiColors.textSecondary, + size: 20, + ), + ], + ), + ), + ), + if (isOpen) + Padding( + padding: const EdgeInsets.fromLTRB( + 16, + 0, + 16, + 16, + ), + child: Text( + questionItem.answer, + style: UiTypography.body1r.textSecondary, + ), + ), + ], + ), + ); + }), + const SizedBox(height: 12), + ], + ); + }), + ], + ), + ); + }, + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/staff_faqs_module.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/staff_faqs_module.dart new file mode 100644 index 00000000..6faf7c3a --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/staff_faqs_module.dart @@ -0,0 +1,52 @@ +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; + +import 'data/repositories_impl/faqs_repository_impl.dart'; +import 'domain/repositories/faqs_repository_interface.dart'; +import 'domain/usecases/get_faqs_usecase.dart'; +import 'domain/usecases/search_faqs_usecase.dart'; +import 'presentation/blocs/faqs_bloc.dart'; +import 'presentation/pages/faqs_page.dart'; + +/// Module for FAQs feature +/// +/// Provides: +/// - Dependency injection for repositories, use cases, and BLoCs +/// - Route definitions delegated to core routing +class FaqsModule extends Module { + @override + void binds(Injector i) { + // Repository + i.addSingleton( + () => FaqsRepositoryImpl(), + ); + + // Use Cases + i.addSingleton( + () => GetFaqsUseCase( + i(), + ), + ); + i.addSingleton( + () => SearchFaqsUseCase( + i(), + ), + ); + + // BLoC + i.add( + () => FaqsBloc( + getFaqsUseCase: i(), + searchFaqsUseCase: i(), + ), + ); + } + + @override + void routes(RouteManager r) { + r.child( + StaffPaths.childRoute(StaffPaths.faqs, StaffPaths.faqs), + child: (_) => const FaqsPage(), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/staff_faqs.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/staff_faqs.dart new file mode 100644 index 00000000..46c3940d --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/staff_faqs.dart @@ -0,0 +1,4 @@ +library staff_faqs; + +export 'src/staff_faqs_module.dart'; +export 'src/presentation/pages/faqs_page.dart'; diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/support/faqs/pubspec.yaml new file mode 100644 index 00000000..e50b0511 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/pubspec.yaml @@ -0,0 +1,29 @@ +name: staff_faqs +description: Frequently Asked Questions feature for staff application. +version: 0.0.1 +publish_to: none +resolution: workspace + +environment: + sdk: '>=3.10.0 <4.0.0' + flutter: ">=3.0.0" + +dependencies: + flutter: + sdk: flutter + flutter_bloc: ^8.1.0 + flutter_modular: ^6.3.0 + equatable: ^2.0.5 + + # Architecture Packages + krow_core: + path: ../../../../../core + design_system: + path: ../../../../../design_system + core_localization: + path: ../../../../../core_localization + +flutter: + uses-material-design: true + assets: + - lib/src/assets/faqs/ diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/assets/legal/privacy_policy.txt b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/assets/legal/privacy_policy.txt new file mode 100644 index 00000000..b632873f --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/assets/legal/privacy_policy.txt @@ -0,0 +1,119 @@ +PRIVACY POLICY + +Effective Date: February 18, 2026 + +1. INTRODUCTION + +Krow Workforce ("we," "us," "our," or "the App") is committed to protecting your privacy. This Privacy Policy explains how we collect, use, disclose, and otherwise process your personal information through our mobile application and related services. + +2. INFORMATION WE COLLECT + +2.1 Information You Provide Directly: +- Account information: name, email address, phone number, password +- Profile information: photo, bio, skills, experience, certifications +- Location data: work location preferences and current location (when enabled) +- Payment information: bank account details, tax identification numbers +- Communication data: messages, support inquiries, feedback + +2.2 Information Collected Automatically: +- Device information: device type, operating system, device identifiers +- Usage data: features accessed, actions taken, time and duration of activities +- Log data: IP address, browser type, pages visited, errors encountered +- Location data: approximate location based on IP address (always) +- Precise location: only when Location Sharing is enabled + +2.3 Information from Third Parties: +- Background check services: verification results +- Banking partners: account verification information +- Payment processors: transaction information + +3. HOW WE USE YOUR INFORMATION + +We use your information to: +- Create and maintain your account +- Process payments and verify employment eligibility +- Improve and optimize our services +- Send you important notifications and updates +- Provide customer support +- Prevent fraud and ensure security +- Comply with legal obligations +- Conduct analytics and research +- Match you with appropriate work opportunities +- Communicate promotional offers (with your consent) + +4. LOCATION DATA & PRIVACY SETTINGS + +4.1 Location Sharing: +You can control location sharing through Privacy Settings: +- Disabled (default): Your approximate location is based on IP address only +- Enabled: Precise location data is collected for better job matching + +4.2 Your Control: +You may enable or disable precise location sharing at any time in the Privacy & Security section of your profile. + +5. DATA RETENTION + +We retain your personal information for as long as: +- Your account is active, plus +- An additional period as required by law or for business purposes + +You may request deletion of your account and associated data by contacting support@krow.com. + +6. DATA SECURITY + +We implement appropriate technical and organizational measures to protect your personal information from unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the internet is 100% secure. + +7. SHARING OF INFORMATION + +We do not sell your personal information. We may share information with: +- Service providers and contractors: who process data on our behalf +- Employers and clients: limited information needed for job matching +- Legal authorities: when required by law +- Business partners: with your explicit consent +- Other users: your name, skills, and ratings (as needed for job matching) + +8. YOUR PRIVACY RIGHTS + +8.1 Access and Correction: +You have the right to access, review, and request correction of your personal information. + +8.2 Data Portability: +You may request a copy of your personal data in a portable format. + +8.3 Deletion: +You may request deletion of your account and personal information, subject to legal obligations. + +8.4 Opt-Out: +You may opt out of marketing communications and certain data processing activities. + +9. CHILDREN'S PRIVACY + +Our App is not intended for individuals under 18 years of age. We do not knowingly collect personal information from children. If we become aware that we have collected information from a child, we will take steps to delete such information immediately. + +10. THIRD-PARTY LINKS + +Our App may contain links to third-party websites. We are not responsible for the privacy practices of these external sites. We encourage you to review their privacy policies. + +11. INTERNATIONAL DATA TRANSFERS + +Your information may be transferred to, stored in, and processed in countries other than your country of residence. These countries may have data protection laws different from your home country. + +12. CHANGES TO THIS POLICY + +We may update this Privacy Policy from time to time. We will notify you of significant changes via email or through the App. Your continued use of the App constitutes your acceptance of the updated Privacy Policy. + +13. CONTACT US + +If you have questions about this Privacy Policy or your personal information, please contact us at: + +Email: privacy@krow.com +Address: Krow Workforce, [Company Address] +Phone: [Support Phone Number] + +14. CALIFORNIA PRIVACY RIGHTS (CCPA) + +If you are a California resident, you have additional rights under the California Consumer Privacy Act (CCPA). Please visit our CCPA Rights page or contact privacy@krow.com for more information. + +15. EUROPEAN PRIVACY RIGHTS (GDPR) + +If you are in the European Union, you have rights under the General Data Protection Regulation (GDPR). These include the right to access, rectification, erasure, and data portability. Contact privacy@krow.com to exercise these rights. diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/assets/legal/terms_of_service.txt b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/assets/legal/terms_of_service.txt new file mode 100644 index 00000000..818cbe06 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/assets/legal/terms_of_service.txt @@ -0,0 +1,61 @@ +TERMS OF SERVICE + +Effective Date: February 18, 2026 + +1. ACCEPTANCE OF TERMS + +By accessing and using the Krow Workforce application ("the App"), you accept and agree to be bound by the terms and provisions of this agreement. If you do not agree to abide by the above, please do not use this service. + +2. USE LICENSE + +Permission is granted to temporarily download one copy of the materials (information or software) on Krow Workforce's App for personal, non-commercial transitory viewing only. This is the grant of a license, not a transfer of title, and under this license you may not: + +a) Modifying or copying the materials +b) Using the materials for any commercial purpose or for any public display +c) Attempting to reverse engineer, disassemble, or decompile any software contained on the App +d) Removing any copyright or other proprietary notations from the materials +e) Transferring the materials to another person or "mirroring" the materials on any other server + +3. DISCLAIMER + +The materials on Krow Workforce's App are provided on an "as is" basis. Krow Workforce makes no warranties, expressed or implied, and hereby disclaims and negates all other warranties including, without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights. + +4. LIMITATIONS + +In no event shall Krow Workforce or its suppliers be liable for any damages (including, without limitation, damages for loss of data or profit, or due to business interruption) arising out of the use or inability to use the materials on Krow Workforce's App, even if Krow Workforce or a Krow Workforce authorized representative has been notified orally or in writing of the possibility of such damage. + +5. ACCURACY OF MATERIALS + +The materials appearing on Krow Workforce's App could include technical, typographical, or photographic errors. Krow Workforce does not warrant that any of the materials on its App are accurate, complete, or current. Krow Workforce may make changes to the materials contained on its App at any time without notice. + +6. MATERIALS DISCLAIMER + +Krow Workforce has not reviewed all of the sites linked to its App and is not responsible for the contents of any such linked site. The inclusion of any link does not imply endorsement by Krow Workforce of the site. Use of any such linked website is at the user's own risk. + +7. MODIFICATIONS + +Krow Workforce may revise these terms of service for its App at any time without notice. By using this App, you are agreeing to be bound by the then current version of these terms of service. + +8. GOVERNING LAW + +These terms and conditions are governed by and construed in accordance with the laws of the jurisdiction in which Krow Workforce is located, and you irrevocably submit to the exclusive jurisdiction of the courts in that location. + +9. LIMITATION OF LIABILITY + +In no case shall Krow Workforce, its staff, or other contributors be liable for any indirect, incidental, consequential, special, or punitive damages arising out of or relating to the use of the App. + +10. USER CONTENT + +You grant Krow Workforce a non-exclusive, royalty-free, perpetual, and irrevocable right to use any content you provide to us, including but not limited to text, images, and information, in any media or format and for any purpose consistent with our business. + +11. INDEMNIFICATION + +You agree to indemnify and hold harmless Krow Workforce and its staff from any and all claims, damages, losses, costs, and expenses, including attorney's fees, arising out of or resulting from your use of the App or violation of these terms. + +12. TERMINATION + +Krow Workforce reserves the right to terminate your account and access to the App at any time, in its sole discretion, for any reason or no reason, with or without notice. + +13. CONTACT INFORMATION + +If you have any questions about these Terms of Service, please contact us at support@krow.com. diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/data/repositories_impl/privacy_settings_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/data/repositories_impl/privacy_settings_repository_impl.dart new file mode 100644 index 00000000..66225fc4 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/data/repositories_impl/privacy_settings_repository_impl.dart @@ -0,0 +1,92 @@ +import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; +import 'package:flutter/services.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; + +import '../../domain/repositories/privacy_settings_repository_interface.dart'; + +/// Data layer implementation of privacy settings repository +/// +/// Handles all backend communication for privacy settings via Data Connect, +/// and loads legal documents from app assets +class PrivacySettingsRepositoryImpl + implements PrivacySettingsRepositoryInterface { + PrivacySettingsRepositoryImpl(this._service); + + final DataConnectService _service; + + @override + Future getProfileVisibility() async { + return _service.run(() async { + // Get current user ID + final String staffId = await _service.getStaffId(); + + // Call Data Connect query: getStaffProfileVisibility + final fdc.QueryResult< + GetStaffProfileVisibilityData, + GetStaffProfileVisibilityVariables + > + response = await _service.connector + .getStaffProfileVisibility(staffId: staffId) + .execute(); + + // Return the profile visibility status from the first result + if (response.data.staff != null) { + return response.data.staff?.isProfileVisible ?? true; + } + + // Default to visible if no staff record found + return true; + }); + } + + @override + Future updateProfileVisibility(bool isVisible) async { + return _service.run(() async { + // Get staff ID for the current user + final String staffId = await _service.getStaffId(); + + // Call Data Connect mutation: UpdateStaffProfileVisibility + await _service.connector + .updateStaffProfileVisibility( + id: staffId, + isProfileVisible: isVisible, + ) + .execute(); + + // Return the requested visibility state + return isVisible; + }); + } + + @override + Future getTermsOfService() async { + return _service.run(() async { + try { + // Load from package asset path + return await rootBundle.loadString( + 'packages/staff_privacy_security/lib/src/assets/legal/terms_of_service.txt', + ); + } catch (e) { + // Final fallback if asset not found + print('Error loading terms of service: $e'); + return 'Terms of Service - Content unavailable. Please contact support@krow.com'; + } + }); + } + + @override + Future getPrivacyPolicy() async { + return _service.run(() async { + try { + // Load from package asset path + return await rootBundle.loadString( + 'packages/staff_privacy_security/lib/src/assets/legal/privacy_policy.txt', + ); + } catch (e) { + // Final fallback if asset not found + print('Error loading privacy policy: $e'); + return 'Privacy Policy - Content unavailable. Please contact privacy@krow.com'; + } + }); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/entities/privacy_settings_entity.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/entities/privacy_settings_entity.dart new file mode 100644 index 00000000..aad50058 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/entities/privacy_settings_entity.dart @@ -0,0 +1,29 @@ +import 'package:equatable/equatable.dart'; + +/// Privacy settings entity representing user privacy preferences +class PrivacySettingsEntity extends Equatable { + /// Whether location sharing during shifts is enabled + final bool locationSharing; + + /// The timestamp when these settings were last updated + final DateTime? updatedAt; + + const PrivacySettingsEntity({ + required this.locationSharing, + this.updatedAt, + }); + + /// Create a copy with optional field overrides + PrivacySettingsEntity copyWith({ + bool? locationSharing, + DateTime? updatedAt, + }) { + return PrivacySettingsEntity( + locationSharing: locationSharing ?? this.locationSharing, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + @override + List get props => [locationSharing, updatedAt]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/repositories/privacy_settings_repository_interface.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/repositories/privacy_settings_repository_interface.dart new file mode 100644 index 00000000..8057a76e --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/repositories/privacy_settings_repository_interface.dart @@ -0,0 +1,16 @@ +/// Interface for privacy settings repository operations +abstract class PrivacySettingsRepositoryInterface { + /// Fetch the current staff member's profile visibility setting + Future getProfileVisibility(); + + /// Update profile visibility preference + /// + /// Returns the updated profile visibility status + Future updateProfileVisibility(bool isVisible); + + /// Fetch terms of service content + Future getTermsOfService(); + + /// Fetch privacy policy content + Future getPrivacyPolicy(); +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/get_privacy_policy_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/get_privacy_policy_usecase.dart new file mode 100644 index 00000000..f7d5fae4 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/get_privacy_policy_usecase.dart @@ -0,0 +1,17 @@ +import '../repositories/privacy_settings_repository_interface.dart'; + +/// Use case to retrieve privacy policy +class GetPrivacyPolicyUseCase { + final PrivacySettingsRepositoryInterface _repository; + + GetPrivacyPolicyUseCase(this._repository); + + /// Execute the use case to get privacy policy + Future call() async { + try { + return await _repository.getPrivacyPolicy(); + } catch (e) { + return 'Privacy Policy is currently unavailable.'; + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/get_profile_visibility_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/get_profile_visibility_usecase.dart new file mode 100644 index 00000000..3b21da61 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/get_profile_visibility_usecase.dart @@ -0,0 +1,19 @@ +import '../repositories/privacy_settings_repository_interface.dart'; + +/// Use case to retrieve the current staff member's profile visibility setting +class GetProfileVisibilityUseCase { + final PrivacySettingsRepositoryInterface _repository; + + GetProfileVisibilityUseCase(this._repository); + + /// Execute the use case to get profile visibility status + /// Returns true if profile is visible, false if hidden + Future call() async { + try { + return await _repository.getProfileVisibility(); + } catch (e) { + // Return default (visible) on error + return true; + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/get_terms_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/get_terms_usecase.dart new file mode 100644 index 00000000..5a68b8b3 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/get_terms_usecase.dart @@ -0,0 +1,17 @@ +import '../repositories/privacy_settings_repository_interface.dart'; + +/// Use case to retrieve terms of service +class GetTermsUseCase { + final PrivacySettingsRepositoryInterface _repository; + + GetTermsUseCase(this._repository); + + /// Execute the use case to get terms of service + Future call() async { + try { + return await _repository.getTermsOfService(); + } catch (e) { + return 'Terms of Service is currently unavailable.'; + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/update_profile_visibility_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/update_profile_visibility_usecase.dart new file mode 100644 index 00000000..9048ae59 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/update_profile_visibility_usecase.dart @@ -0,0 +1,32 @@ +import 'package:equatable/equatable.dart'; + +import '../repositories/privacy_settings_repository_interface.dart'; + +/// Parameters for updating profile visibility +class UpdateProfileVisibilityParams extends Equatable { + /// Whether to show (true) or hide (false) the profile + final bool isVisible; + + const UpdateProfileVisibilityParams({required this.isVisible}); + + @override + List get props => [isVisible]; +} + +/// Use case to update profile visibility setting +class UpdateProfileVisibilityUseCase { + final PrivacySettingsRepositoryInterface _repository; + + UpdateProfileVisibilityUseCase(this._repository); + + /// Execute the use case to update profile visibility + /// Returns the updated visibility status + Future call(UpdateProfileVisibilityParams params) async { + try { + return await _repository.updateProfileVisibility(params.isVisible); + } catch (e) { + // Return the requested state on error (optimistic) + return params.isVisible; + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/legal/privacy_policy_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/legal/privacy_policy_cubit.dart new file mode 100644 index 00000000..3fa688a4 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/legal/privacy_policy_cubit.dart @@ -0,0 +1,55 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../domain/usecases/get_privacy_policy_usecase.dart'; + +/// State for Privacy Policy cubit +class PrivacyPolicyState { + final String? content; + final bool isLoading; + final String? error; + + const PrivacyPolicyState({ + this.content, + this.isLoading = false, + this.error, + }); + + PrivacyPolicyState copyWith({ + String? content, + bool? isLoading, + String? error, + }) { + return PrivacyPolicyState( + content: content ?? this.content, + isLoading: isLoading ?? this.isLoading, + error: error ?? this.error, + ); + } +} + +/// Cubit for managing Privacy Policy content +class PrivacyPolicyCubit extends Cubit { + final GetPrivacyPolicyUseCase _getPrivacyPolicyUseCase; + + PrivacyPolicyCubit({ + required GetPrivacyPolicyUseCase getPrivacyPolicyUseCase, + }) : _getPrivacyPolicyUseCase = getPrivacyPolicyUseCase, + super(const PrivacyPolicyState()); + + /// Fetch privacy policy content + Future fetchPrivacyPolicy() async { + emit(state.copyWith(isLoading: true, error: null)); + try { + final String content = await _getPrivacyPolicyUseCase(); + emit(state.copyWith( + content: content, + isLoading: false, + )); + } catch (e) { + emit(state.copyWith( + isLoading: false, + error: e.toString(), + )); + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/legal/terms_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/legal/terms_cubit.dart new file mode 100644 index 00000000..f85b3d3e --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/legal/terms_cubit.dart @@ -0,0 +1,55 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../domain/usecases/get_terms_usecase.dart'; + +/// State for Terms of Service cubit +class TermsState { + final String? content; + final bool isLoading; + final String? error; + + const TermsState({ + this.content, + this.isLoading = false, + this.error, + }); + + TermsState copyWith({ + String? content, + bool? isLoading, + String? error, + }) { + return TermsState( + content: content ?? this.content, + isLoading: isLoading ?? this.isLoading, + error: error ?? this.error, + ); + } +} + +/// Cubit for managing Terms of Service content +class TermsCubit extends Cubit { + final GetTermsUseCase _getTermsUseCase; + + TermsCubit({ + required GetTermsUseCase getTermsUseCase, + }) : _getTermsUseCase = getTermsUseCase, + super(const TermsState()); + + /// Fetch terms of service content + Future fetchTerms() async { + emit(state.copyWith(isLoading: true, error: null)); + try { + final String content = await _getTermsUseCase(); + emit(state.copyWith( + content: content, + isLoading: false, + )); + } catch (e) { + emit(state.copyWith( + isLoading: false, + error: e.toString(), + )); + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/privacy_security_bloc.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/privacy_security_bloc.dart new file mode 100644 index 00000000..d333824d --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/privacy_security_bloc.dart @@ -0,0 +1,143 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:equatable/equatable.dart'; + +import '../../domain/usecases/get_profile_visibility_usecase.dart'; +import '../../domain/usecases/update_profile_visibility_usecase.dart'; +import '../../domain/usecases/get_terms_usecase.dart'; +import '../../domain/usecases/get_privacy_policy_usecase.dart'; + +part 'privacy_security_event.dart'; +part 'privacy_security_state.dart'; + +/// BLoC managing privacy and security settings state +class PrivacySecurityBloc + extends Bloc { + final GetProfileVisibilityUseCase _getProfileVisibilityUseCase; + final UpdateProfileVisibilityUseCase _updateProfileVisibilityUseCase; + final GetTermsUseCase _getTermsUseCase; + final GetPrivacyPolicyUseCase _getPrivacyPolicyUseCase; + + PrivacySecurityBloc({ + required GetProfileVisibilityUseCase getProfileVisibilityUseCase, + required UpdateProfileVisibilityUseCase updateProfileVisibilityUseCase, + required GetTermsUseCase getTermsUseCase, + required GetPrivacyPolicyUseCase getPrivacyPolicyUseCase, + }) : _getProfileVisibilityUseCase = getProfileVisibilityUseCase, + _updateProfileVisibilityUseCase = updateProfileVisibilityUseCase, + _getTermsUseCase = getTermsUseCase, + _getPrivacyPolicyUseCase = getPrivacyPolicyUseCase, + super(const PrivacySecurityState()) { + on(_onFetchProfileVisibility); + on(_onUpdateProfileVisibility); + on(_onFetchTerms); + on(_onFetchPrivacyPolicy); + on(_onClearProfileVisibilityUpdated); + } + + Future _onFetchProfileVisibility( + FetchProfileVisibilityEvent event, + Emitter emit, + ) async { + emit(state.copyWith(isLoading: true, error: null)); + + try { + final bool isVisible = await _getProfileVisibilityUseCase.call(); + emit( + state.copyWith( + isLoading: false, + isProfileVisible: isVisible, + ), + ); + } catch (e) { + emit( + state.copyWith( + isLoading: false, + error: 'Failed to fetch profile visibility', + ), + ); + } + } + + Future _onUpdateProfileVisibility( + UpdateProfileVisibilityEvent event, + Emitter emit, + ) async { + emit(state.copyWith(isUpdating: true, error: null, profileVisibilityUpdated: false)); + + try { + final bool isVisible = await _updateProfileVisibilityUseCase.call( + UpdateProfileVisibilityParams(isVisible: event.isVisible), + ); + emit( + state.copyWith( + isUpdating: false, + isProfileVisible: isVisible, + profileVisibilityUpdated: true, + ), + ); + } catch (e) { + emit( + state.copyWith( + isUpdating: false, + error: 'Failed to update profile visibility', + profileVisibilityUpdated: false, + ), + ); + } + } + + Future _onFetchTerms( + FetchTermsEvent event, + Emitter emit, + ) async { + emit(state.copyWith(isLoadingTerms: true, error: null)); + + try { + final String content = await _getTermsUseCase.call(); + emit( + state.copyWith( + isLoadingTerms: false, + termsContent: content, + ), + ); + } catch (e) { + emit( + state.copyWith( + isLoadingTerms: false, + error: 'Failed to fetch terms of service', + ), + ); + } + } + + Future _onFetchPrivacyPolicy( + FetchPrivacyPolicyEvent event, + Emitter emit, + ) async { + emit(state.copyWith(isLoadingPrivacyPolicy: true, error: null)); + + try { + final String content = await _getPrivacyPolicyUseCase.call(); + emit( + state.copyWith( + isLoadingPrivacyPolicy: false, + privacyPolicyContent: content, + ), + ); + } catch (e) { + emit( + state.copyWith( + isLoadingPrivacyPolicy: false, + error: 'Failed to fetch privacy policy', + ), + ); + } + } + + void _onClearProfileVisibilityUpdated( + ClearProfileVisibilityUpdatedEvent event, + Emitter emit, + ) { + emit(state.copyWith(profileVisibilityUpdated: false)); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/privacy_security_event.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/privacy_security_event.dart new file mode 100644 index 00000000..6dbfcfdd --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/privacy_security_event.dart @@ -0,0 +1,40 @@ +part of 'privacy_security_bloc.dart'; + +/// Base class for privacy security BLoC events +abstract class PrivacySecurityEvent extends Equatable { + const PrivacySecurityEvent(); + + @override + List get props => []; +} + +/// Event to fetch current profile visibility setting +class FetchProfileVisibilityEvent extends PrivacySecurityEvent { + const FetchProfileVisibilityEvent(); +} + +/// Event to update profile visibility +class UpdateProfileVisibilityEvent extends PrivacySecurityEvent { + /// Whether to show (true) or hide (false) the profile + final bool isVisible; + + const UpdateProfileVisibilityEvent({required this.isVisible}); + + @override + List get props => [isVisible]; +} + +/// Event to fetch terms of service +class FetchTermsEvent extends PrivacySecurityEvent { + const FetchTermsEvent(); +} + +/// Event to fetch privacy policy +class FetchPrivacyPolicyEvent extends PrivacySecurityEvent { + const FetchPrivacyPolicyEvent(); +} + +/// Event to clear the profile visibility updated flag after showing snackbar +class ClearProfileVisibilityUpdatedEvent extends PrivacySecurityEvent { + const ClearProfileVisibilityUpdatedEvent(); +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/privacy_security_state.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/privacy_security_state.dart new file mode 100644 index 00000000..a84666ad --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/privacy_security_state.dart @@ -0,0 +1,83 @@ +part of 'privacy_security_bloc.dart'; + +/// State for privacy security BLoC +class PrivacySecurityState extends Equatable { + /// Current profile visibility setting (true = visible, false = hidden) + final bool isProfileVisible; + + /// Whether profile visibility is currently loading + final bool isLoading; + + /// Whether profile visibility is currently being updated + final bool isUpdating; + + /// Whether the profile visibility was just successfully updated + final bool profileVisibilityUpdated; + + /// Terms of service content + final String? termsContent; + + /// Whether terms are currently loading + final bool isLoadingTerms; + + /// Privacy policy content + final String? privacyPolicyContent; + + /// Whether privacy policy is currently loading + final bool isLoadingPrivacyPolicy; + + /// Error message, if any + final String? error; + + const PrivacySecurityState({ + this.isProfileVisible = true, + this.isLoading = false, + this.isUpdating = false, + this.profileVisibilityUpdated = false, + this.termsContent, + this.isLoadingTerms = false, + this.privacyPolicyContent, + this.isLoadingPrivacyPolicy = false, + this.error, + }); + + /// Create a copy with optional field overrides + PrivacySecurityState copyWith({ + bool? isProfileVisible, + bool? isLoading, + bool? isUpdating, + bool? profileVisibilityUpdated, + String? termsContent, + bool? isLoadingTerms, + String? privacyPolicyContent, + bool? isLoadingPrivacyPolicy, + String? error, + }) { + return PrivacySecurityState( + isProfileVisible: isProfileVisible ?? this.isProfileVisible, + isLoading: isLoading ?? this.isLoading, + isUpdating: isUpdating ?? this.isUpdating, + profileVisibilityUpdated: profileVisibilityUpdated ?? this.profileVisibilityUpdated, + termsContent: termsContent ?? this.termsContent, + isLoadingTerms: isLoadingTerms ?? this.isLoadingTerms, + privacyPolicyContent: privacyPolicyContent ?? this.privacyPolicyContent, + isLoadingPrivacyPolicy: + isLoadingPrivacyPolicy ?? this.isLoadingPrivacyPolicy, + error: error, + ); + } + + @override + List get props => [ + isProfileVisible, + isLoading, + isUpdating, + profileVisibilityUpdated, + termsContent, + isLoadingTerms, + privacyPolicyContent, + isLoadingPrivacyPolicy, + error, + ]; +} + diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/privacy_policy_page.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/privacy_policy_page.dart new file mode 100644 index 00000000..9ed11bd7 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/privacy_policy_page.dart @@ -0,0 +1,66 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; + +import '../../blocs/legal/privacy_policy_cubit.dart'; + +/// Page displaying the Privacy Policy document +class PrivacyPolicyPage extends StatelessWidget { + const PrivacyPolicyPage({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: UiAppBar( + title: t.staff_privacy_security.privacy_policy.title, + showBackButton: true, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Container(color: UiColors.border, height: 1), + ), + ), + body: BlocProvider( + create: (BuildContext context) => Modular.get()..fetchPrivacyPolicy(), + child: BlocBuilder( + builder: (BuildContext context, PrivacyPolicyState state) { + if (state.isLoading) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (state.error != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Text( + 'Error loading Privacy Policy: ${state.error}', + textAlign: TextAlign.center, + style: UiTypography.body2r.copyWith( + color: UiColors.textSecondary, + ), + ), + ), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space5), + child: Text( + state.content ?? 'No content available', + style: UiTypography.body2r.copyWith( + height: 1.6, + color: UiColors.textPrimary, + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/terms_of_service_page.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/terms_of_service_page.dart new file mode 100644 index 00000000..2f72c2f3 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/terms_of_service_page.dart @@ -0,0 +1,66 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; + +import '../../blocs/legal/terms_cubit.dart'; + +/// Page displaying the Terms of Service document +class TermsOfServicePage extends StatelessWidget { + const TermsOfServicePage({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: UiAppBar( + title: t.staff_privacy_security.terms_of_service.title, + showBackButton: true, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Container(color: UiColors.border, height: 1), + ), + ), + body: BlocProvider( + create: (BuildContext context) => Modular.get()..fetchTerms(), + child: BlocBuilder( + builder: (BuildContext context, TermsState state) { + if (state.isLoading) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (state.error != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Text( + 'Error loading Terms of Service: ${state.error}', + textAlign: TextAlign.center, + style: UiTypography.body2r.copyWith( + color: UiColors.textSecondary, + ), + ), + ), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space5), + child: Text( + state.content ?? 'No content available', + style: UiTypography.body2r.copyWith( + height: 1.6, + color: UiColors.textPrimary, + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/privacy_security_page.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/privacy_security_page.dart new file mode 100644 index 00000000..28749dbe --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/privacy_security_page.dart @@ -0,0 +1,53 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; + +import '../blocs/privacy_security_bloc.dart'; +import '../widgets/legal/legal_section_widget.dart'; +import '../widgets/privacy/privacy_section_widget.dart'; + +/// Page displaying privacy & security settings for staff +class PrivacySecurityPage extends StatelessWidget { + const PrivacySecurityPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: UiAppBar( + title: t.staff_privacy_security.title, + showBackButton: true, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Container(color: UiColors.border, height: 1), + ), + ), + body: BlocProvider.value( + value: Modular.get() + ..add(const FetchProfileVisibilityEvent()), + child: BlocBuilder( + builder: (BuildContext context, PrivacySecurityState state) { + if (state.isLoading) { + return const UiLoadingPage(); + } + + return const SingleChildScrollView( + padding: EdgeInsets.all(UiConstants.space6), + child: Column( + spacing: UiConstants.space6, + children: [ + // Privacy Section + PrivacySectionWidget(), + + // Legal Section + LegalSectionWidget(), + ], + ), + ); + }, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/legal/legal_section_widget.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/legal/legal_section_widget.dart new file mode 100644 index 00000000..d50540a3 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/legal/legal_section_widget.dart @@ -0,0 +1,70 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; + +import '../../blocs/privacy_security_bloc.dart'; +import '../settings_action_tile_widget.dart'; +import '../settings_divider_widget.dart'; +import '../settings_section_header_widget.dart'; + +/// Widget displaying legal documents (Terms of Service and Privacy Policy) +class LegalSectionWidget extends StatelessWidget { + const LegalSectionWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + spacing: UiConstants.space4, + + children: [ + // Legal Section Header + SettingsSectionHeader( + title: t.staff_privacy_security.legal_section, + icon: UiIcons.shield, + ), + + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all(color: UiColors.border), + ), + child: Column( + children: [ + SettingsActionTile( + title: t.staff_privacy_security.terms_of_service.title, + onTap: () => _navigateToTerms(context), + ), + const SettingsDivider(), + SettingsActionTile( + title: t.staff_privacy_security.privacy_policy.title, + onTap: () => _navigateToPrivacyPolicy(context), + ), + ], + ), + ), + ], + ); + } + + /// Navigate to terms of service page + void _navigateToTerms(BuildContext context) { + BlocProvider.of(context).add(const FetchTermsEvent()); + + // Navigate using typed navigator + Modular.to.toTermsOfService(); + } + + /// Navigate to privacy policy page + void _navigateToPrivacyPolicy(BuildContext context) { + BlocProvider.of( + context, + ).add(const FetchPrivacyPolicyEvent()); + + // Navigate using typed navigator + Modular.to.toPrivacyPolicy(); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/privacy/privacy_section_widget.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/privacy/privacy_section_widget.dart new file mode 100644 index 00000000..c8a54a63 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/privacy/privacy_section_widget.dart @@ -0,0 +1,70 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../blocs/privacy_security_bloc.dart'; +import '../settings_section_header_widget.dart'; +import '../settings_switch_tile_widget.dart'; + +/// Widget displaying privacy settings including profile visibility preference +class PrivacySectionWidget extends StatelessWidget { + const PrivacySectionWidget({super.key}); + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (BuildContext context, PrivacySecurityState state) { + // Show success message when profile visibility update just completed + if (state.profileVisibilityUpdated && state.error == null) { + UiSnackbar.show( + context, + message: t.staff_privacy_security.success.profile_visibility_updated, + type: UiSnackbarType.success, + ); + // Clear the flag after showing the snackbar + context.read().add( + const ClearProfileVisibilityUpdatedEvent(), + ); + } + }, + child: BlocBuilder( + builder: (BuildContext context, PrivacySecurityState state) { + return Column( + children: [ + // Privacy Section Header + SettingsSectionHeader( + title: t.staff_privacy_security.privacy_section, + icon: UiIcons.eye, + ), + const SizedBox(height: 12.0), + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all( + color: UiColors.border, + ), + ), + child: Column( + children: [ + SettingsSwitchTile( + title: t.staff_privacy_security.profile_visibility.title, + subtitle: t.staff_privacy_security.profile_visibility.subtitle, + value: state.isProfileVisible, + onChanged: (bool value) { + BlocProvider.of(context).add( + UpdateProfileVisibilityEvent(isVisible: value), + ); + }, + ), + ], + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_action_tile_widget.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_action_tile_widget.dart new file mode 100644 index 00000000..2e258f64 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_action_tile_widget.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:design_system/design_system.dart'; + +/// Reusable widget for action tile (tap to navigate) +class SettingsActionTile extends StatelessWidget { + /// The title of the action + final String title; + + /// Optional subtitle describing the action + final String? subtitle; + + /// Callback when tile is tapped + final VoidCallback onTap; + + const SettingsActionTile({ + Key? key, + required this.title, + this.subtitle, + required this.onTap, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: UiTypography.body2r.copyWith( + fontWeight: FontWeight.w500, + ), + ), + if (subtitle != null) ...[ + SizedBox(height: UiConstants.space1), + Text( + subtitle!, + style: UiTypography.footnote1r.copyWith( + color: UiColors.muted, + ), + ), + ], + ], + ), + ), + const Icon( + UiIcons.chevronRight, + size: 16, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_divider_widget.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_divider_widget.dart new file mode 100644 index 00000000..349ab271 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_divider_widget.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; +import 'package:design_system/design_system.dart'; + +/// Divider widget for separating items within settings sections +class SettingsDivider extends StatelessWidget { + const SettingsDivider({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Divider( + height: 1, + color: UiColors.border, + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_section_header_widget.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_section_header_widget.dart new file mode 100644 index 00000000..aca1bf27 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_section_header_widget.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:design_system/design_system.dart'; + +/// Reusable widget for settings section header with icon +class SettingsSectionHeader extends StatelessWidget { + /// The title of the section + final String title; + + /// The icon to display next to the title + final IconData icon; + + const SettingsSectionHeader({ + Key? key, + required this.title, + required this.icon, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon( + icon, + size: 20, + color: UiColors.primary, + ), + SizedBox(width: UiConstants.space2), + Text( + title, + style: UiTypography.body1r.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_switch_tile_widget.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_switch_tile_widget.dart new file mode 100644 index 00000000..7e4df2a4 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_switch_tile_widget.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:design_system/design_system.dart'; + +/// Reusable widget for toggle tile in privacy settings +class SettingsSwitchTile extends StatelessWidget { + /// The title of the setting + final String title; + + /// The subtitle describing the setting + final String subtitle; + + /// Current toggle value + final bool value; + + /// Callback when toggle is changed + final ValueChanged onChanged; + + const SettingsSwitchTile({ + Key? key, + required this.title, + required this.subtitle, + required this.value, + required this.onChanged, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: UiTypography.body2r), + Text(subtitle, style: UiTypography.footnote1r.textSecondary), + ], + ), + ), + Switch(value: value, onChanged: onChanged), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/staff_privacy_security_module.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/staff_privacy_security_module.dart new file mode 100644 index 00000000..22b0d405 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/staff_privacy_security_module.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; + +import 'data/repositories_impl/privacy_settings_repository_impl.dart'; +import 'domain/repositories/privacy_settings_repository_interface.dart'; +import 'domain/usecases/get_privacy_policy_usecase.dart'; +import 'domain/usecases/get_profile_visibility_usecase.dart'; +import 'domain/usecases/get_terms_usecase.dart'; +import 'domain/usecases/update_profile_visibility_usecase.dart'; +import 'presentation/blocs/legal/privacy_policy_cubit.dart'; +import 'presentation/blocs/legal/terms_cubit.dart'; +import 'presentation/blocs/privacy_security_bloc.dart'; +import 'presentation/pages/legal/privacy_policy_page.dart'; +import 'presentation/pages/legal/terms_of_service_page.dart'; +import 'presentation/pages/privacy_security_page.dart'; + +/// Module for privacy security feature +/// +/// Provides: +/// - Dependency injection for repositories, use cases, and BLoCs +/// - Route definitions delegated to core routing +class PrivacySecurityModule extends Module { + @override + void binds(Injector i) { + // Repository + i.addSingleton( + () => PrivacySettingsRepositoryImpl( + Modular.get(), + ), + ); + + // Use Cases + i.addSingleton( + () => GetProfileVisibilityUseCase( + i(), + ), + ); + i.addSingleton( + () => UpdateProfileVisibilityUseCase( + i(), + ), + ); + i.addSingleton( + () => GetTermsUseCase( + i(), + ), + ); + i.addSingleton( + () => GetPrivacyPolicyUseCase( + i(), + ), + ); + + // BLoC + i.add( + () => PrivacySecurityBloc( + getProfileVisibilityUseCase: i(), + updateProfileVisibilityUseCase: i(), + getTermsUseCase: i(), + getPrivacyPolicyUseCase: i(), + ), + ); + + // Legal Cubits + i.add( + () => TermsCubit( + getTermsUseCase: i(), + ), + ); + + i.add( + () => PrivacyPolicyCubit( + getPrivacyPolicyUseCase: i(), + ), + ); + } + + @override + void routes(RouteManager r) { + // Main privacy security page + r.child( + StaffPaths.childRoute( + StaffPaths.privacySecurity, + StaffPaths.privacySecurity, + ), + child: (BuildContext context) => const PrivacySecurityPage(), + ); + + // Terms of Service page + r.child( + StaffPaths.childRoute( + StaffPaths.privacySecurity, + StaffPaths.termsOfService, + ), + child: (BuildContext context) => const TermsOfServicePage(), + ); + + // Privacy Policy page + r.child( + StaffPaths.childRoute( + StaffPaths.privacySecurity, + StaffPaths.privacyPolicy, + ), + child: (BuildContext context) => const PrivacyPolicyPage(), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/staff_privacy_security.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/staff_privacy_security.dart new file mode 100644 index 00000000..a638651d --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/staff_privacy_security.dart @@ -0,0 +1,12 @@ +export 'src/domain/entities/privacy_settings_entity.dart'; +export 'src/domain/repositories/privacy_settings_repository_interface.dart'; +export 'src/domain/usecases/get_terms_usecase.dart'; +export 'src/domain/usecases/get_privacy_policy_usecase.dart'; +export 'src/data/repositories_impl/privacy_settings_repository_impl.dart'; +export 'src/presentation/blocs/privacy_security_bloc.dart'; +export 'src/presentation/pages/privacy_security_page.dart'; +export 'src/presentation/widgets/settings_switch_tile_widget.dart'; +export 'src/presentation/widgets/settings_action_tile_widget.dart'; +export 'src/presentation/widgets/settings_section_header_widget.dart'; +export 'src/presentation/widgets/settings_divider_widget.dart'; +export 'src/staff_privacy_security_module.dart'; diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/pubspec.yaml new file mode 100644 index 00000000..d55e3e24 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/pubspec.yaml @@ -0,0 +1,42 @@ +name: staff_privacy_security +description: Privacy & Security settings feature for staff application. +version: 0.0.1 +publish_to: none +resolution: workspace + +environment: + sdk: '>=3.10.0 <4.0.0' + flutter: ">=3.0.0" + +dependencies: + flutter: + sdk: flutter + flutter_bloc: ^8.1.0 + flutter_modular: ^6.3.0 + equatable: ^2.0.5 + firebase_data_connect: ^0.2.2+1 + url_launcher: ^6.2.0 + + # Architecture Packages + krow_domain: + path: ../../../../../domain + krow_data_connect: + path: ../../../../../data_connect + krow_core: + path: ../../../../../core + design_system: + path: ../../../../../design_system + core_localization: + path: ../../../../../core_localization + + +dev_dependencies: + flutter_test: + sdk: flutter + bloc_test: ^9.1.0 + mocktail: ^1.0.0 + +flutter: + uses-material-design: true + assets: + - lib/src/assets/legal/ diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart index 9d799fcb..4428a780 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart @@ -15,6 +15,8 @@ class ShiftsRepositoryImpl // Cache: ApplicationID -> RoleID (For Accept/Decline w/ Update mutation) final Map _appToRoleIdMap = {}; + // This need to be an APPLICATION + // THERE SHOULD BE APPLICATIONSTATUS and SHIFTSTATUS enums in the domain layer to avoid this string mapping and potential bugs. @override Future> getMyShifts({ required DateTime start, @@ -187,8 +189,6 @@ class ShiftsRepositoryImpl .listShiftRolesByVendorId(vendorId: vendorId) .execute()); - - final allShiftRoles = result.data.shiftRoles; // Fetch my applications to filter out already booked shifts diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart index 1b6e1592..32ffc356 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart @@ -175,30 +175,30 @@ class _ShiftsPageState extends State { child: state is ShiftsLoading ? const Center(child: CircularProgressIndicator()) : state is ShiftsError - ? Center( - child: Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - translateErrorKey(state.message), - style: UiTypography.body2r.textSecondary, - textAlign: TextAlign.center, - ), - ], + ? Center( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + translateErrorKey(state.message), + style: UiTypography.body2r.textSecondary, + textAlign: TextAlign.center, ), - ), - ) - : _buildTabContent( + ], + ), + ), + ) + : _buildTabContent( myShifts, - pendingAssignments, - cancelledShifts, - availableJobs, - historyShifts, - availableLoading, - historyLoading, - ), + pendingAssignments, + cancelledShifts, + availableJobs, + historyShifts, + availableLoading, + historyLoading, + ), ), ], ), @@ -254,14 +254,14 @@ class _ShiftsPageState extends State { onTap: !enabled ? null : () { - setState(() => _activeTab = id); - if (id == 'history') { - _bloc.add(LoadHistoryShiftsEvent()); - } - if (id == 'find') { - _bloc.add(LoadAvailableShiftsEvent()); - } - }, + setState(() => _activeTab = id); + if (id == 'history') { + _bloc.add(LoadHistoryShiftsEvent()); + } + if (id == 'find') { + _bloc.add(LoadAvailableShiftsEvent()); + } + }, child: Container( padding: const EdgeInsets.symmetric( vertical: UiConstants.space2, @@ -290,9 +290,17 @@ class _ShiftsPageState extends State { Flexible( child: Text( label, - style: (isActive ? UiTypography.body3m.copyWith(color: UiColors.primary) : UiTypography.body3m.white).copyWith( - color: !enabled ? UiColors.white.withValues(alpha: 0.5) : null, - ), + style: + (isActive + ? UiTypography.body3m.copyWith( + color: UiColors.primary, + ) + : UiTypography.body3m.white) + .copyWith( + color: !enabled + ? UiColors.white.withValues(alpha: 0.5) + : null, + ), overflow: TextOverflow.ellipsis, ), ), diff --git a/apps/mobile/packages/features/staff/shifts/pubspec.yaml b/apps/mobile/packages/features/staff/shifts/pubspec.yaml index 8315559b..0f23b89c 100644 --- a/apps/mobile/packages/features/staff/shifts/pubspec.yaml +++ b/apps/mobile/packages/features/staff/shifts/pubspec.yaml @@ -32,6 +32,8 @@ dependencies: url_launcher: ^6.3.1 firebase_auth: ^6.1.4 firebase_data_connect: ^0.2.2+2 + meta: ^1.17.0 + bloc: ^8.1.4 dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart index c40027f1..fd5ddc74 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart @@ -12,11 +12,13 @@ import 'package:staff_home/staff_home.dart'; import 'package:staff_main/src/presentation/blocs/staff_main_cubit.dart'; import 'package:staff_main/src/presentation/pages/staff_main_page.dart'; import 'package:staff_payments/staff_payements.dart'; +import 'package:staff_privacy_security/staff_privacy_security.dart'; import 'package:staff_profile/staff_profile.dart'; import 'package:staff_profile_experience/staff_profile_experience.dart'; import 'package:staff_profile_info/staff_profile_info.dart'; import 'package:staff_shifts/staff_shifts.dart'; import 'package:staff_tax_forms/staff_tax_forms.dart'; +import 'package:staff_faqs/staff_faqs.dart'; import 'package:staff_time_card/staff_time_card.dart'; class StaffMainModule extends Module { @@ -93,9 +95,17 @@ class StaffMainModule extends Module { StaffPaths.childRoute(StaffPaths.main, StaffPaths.availability), module: StaffAvailabilityModule(), ); + r.module( + StaffPaths.childRoute(StaffPaths.main, StaffPaths.privacySecurity), + module: PrivacySecurityModule(), + ); r.module( StaffPaths.childRoute(StaffPaths.main, StaffPaths.shiftDetailsRoute), module: ShiftDetailsModule(), ); + r.module( + StaffPaths.childRoute(StaffPaths.main, StaffPaths.faqs), + module: FaqsModule(), + ); } } diff --git a/apps/mobile/packages/features/staff/staff_main/pubspec.yaml b/apps/mobile/packages/features/staff/staff_main/pubspec.yaml index 2f3788f1..f31d21a8 100644 --- a/apps/mobile/packages/features/staff/staff_main/pubspec.yaml +++ b/apps/mobile/packages/features/staff/staff_main/pubspec.yaml @@ -20,6 +20,8 @@ dependencies: path: ../../../design_system core_localization: path: ../../../core_localization + krow_core: + path: ../../../krow_core # Features staff_home: @@ -52,6 +54,10 @@ dependencies: path: ../availability staff_clock_in: path: ../clock_in + staff_privacy_security: + path: ../profile_sections/support/privacy_security + staff_faqs: + path: ../profile_sections/support/faqs dev_dependencies: flutter_test: diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 25c3fd23..d9afe13f 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -1290,6 +1290,20 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.1" + staff_faqs: + dependency: transitive + description: + path: "packages/features/staff/profile_sections/support/faqs" + relative: true + source: path + version: "0.0.1" + staff_privacy_security: + dependency: transitive + description: + path: "packages/features/staff/profile_sections/support/privacy_security" + relative: true + source: path + version: "0.0.1" stream_channel: dependency: transitive description: diff --git a/backend/dataconnect/connector/application/queries.gql b/backend/dataconnect/connector/application/queries.gql index 4b7db9af..4a6d6396 100644 --- a/backend/dataconnect/connector/application/queries.gql +++ b/backend/dataconnect/connector/application/queries.gql @@ -543,6 +543,36 @@ query listAcceptedApplicationsByShiftRoleKey( } } +query listOverlappingAcceptedApplicationsByStaff( + $staffId: UUID! + $newStart: Timestamp! + $newEnd: Timestamp! + $offset: Int + $limit: Int +) @auth(level: USER) { + applications( + where: { + staffId: { eq: $staffId } + status: { in: [ CONFIRMED, CHECKED_IN, CHECKED_OUT, LATE ] } + shiftRole: { + startTime: { lt: $newEnd } + endTime: { gt: $newStart } + } + } + offset: $offset + limit: $limit + orderBy: { appliedAt: ASC } + ) { + id + shiftId + roleId + checkInTime + checkOutTime + staff { id fullName email phone photoUrl } + shiftRole { startTime endTime } + } +} + #getting staffs of an shiftrole status for orders of the day view client query listAcceptedApplicationsByBusinessForDay( $businessId: UUID! diff --git a/backend/dataconnect/connector/order/mutations.gql b/backend/dataconnect/connector/order/mutations.gql index 32423968..95eebf54 100644 --- a/backend/dataconnect/connector/order/mutations.gql +++ b/backend/dataconnect/connector/order/mutations.gql @@ -15,9 +15,9 @@ mutation createOrder( $shifts: Any $requested: Int $teamHubId: UUID! - $recurringDays: Any + $recurringDays: [String!] $permanentStartDate: Timestamp - $permanentDays: Any + $permanentDays: [String!] $notes: String $detectedConflicts: Any $poReference: String @@ -64,8 +64,8 @@ mutation updateOrder( $shifts: Any $requested: Int $teamHubId: UUID! - $recurringDays: Any - $permanentDays: Any + $recurringDays: [String!] + $permanentDays: [String!] $notes: String $detectedConflicts: Any $poReference: String diff --git a/backend/dataconnect/connector/shiftRole/queries.gql b/backend/dataconnect/connector/shiftRole/queries.gql index ffba13ae..07720bf0 100644 --- a/backend/dataconnect/connector/shiftRole/queries.gql +++ b/backend/dataconnect/connector/shiftRole/queries.gql @@ -354,7 +354,7 @@ query listShiftRolesByBusinessAndDateRange( locationAddress title status - order { id eventName } + order { id eventName orderType } } } } diff --git a/backend/dataconnect/connector/staff/mutations.gql b/backend/dataconnect/connector/staff/mutations.gql index 797ca1bd..23f9b0c7 100644 --- a/backend/dataconnect/connector/staff/mutations.gql +++ b/backend/dataconnect/connector/staff/mutations.gql @@ -214,3 +214,12 @@ mutation UpdateStaff( mutation DeleteStaff($id: UUID!) @auth(level: USER) { staff_delete(id: $id) } + +mutation UpdateStaffProfileVisibility($id: UUID!, $isProfileVisible: Boolean!) @auth(level: USER) { + staff_update( + id: $id + data: { + isProfileVisible: $isProfileVisible + } + ) +} diff --git a/backend/dataconnect/connector/staff/queries.gql b/backend/dataconnect/connector/staff/queries.gql index aecf8891..61bb7113 100644 --- a/backend/dataconnect/connector/staff/queries.gql +++ b/backend/dataconnect/connector/staff/queries.gql @@ -204,3 +204,10 @@ query filterStaff( zipCode } } + +query getStaffProfileVisibility($staffId: UUID!) @auth(level: USER) { + staff(id: $staffId) { + id + isProfileVisible + } +} diff --git a/backend/dataconnect/schema/order.gql b/backend/dataconnect/schema/order.gql index 1c815e60..5ab05abb 100644 --- a/backend/dataconnect/schema/order.gql +++ b/backend/dataconnect/schema/order.gql @@ -52,10 +52,10 @@ type Order @table(name: "orders", key: ["id"]) { startDate: Timestamp #for recurring and permanent endDate: Timestamp #for recurring and permanent - recurringDays: Any @col(dataType: "jsonb") + recurringDays: [String!] poReference: String - permanentDays: Any @col(dataType: "jsonb") + permanentDays: [String!] detectedConflicts: Any @col(dataType:"jsonb") notes: String diff --git a/docs/ARCHITECTURE/architecture.md b/docs/ARCHITECTURE/architecture.md new file mode 100644 index 00000000..b84f9861 --- /dev/null +++ b/docs/ARCHITECTURE/architecture.md @@ -0,0 +1,152 @@ +# Krow Platform: System Architecture Overview + +## 1. Executive Summary: The Business Purpose +The **Krow Platform** is an end-to-end workforce management ecosystem designed to bridge the gap between businesses that need staff ("Clients") and the temporary workers who fill those roles ("Staff"). + +Traditionally, this process involves phone calls, paper timesheets, and manual payroll. Krow digitizes the entire lifecycle: +1. **Finding Work:** Clients post shifts instantly; workers claim them via mobile. +2. **Doing Work:** GPS-verified clock-ins and digital timesheets ensure accuracy. +3. **Managing Business:** A web dashboard provides analytics, billing, and compliance oversight. + +The system's goal is to reduce administrative friction, ensure legal compliance, and optimize labor costs through automation and real-time data. + +## 2. The Application Ecosystem +The platform consists of three distinct applications, each tailored to a specific user group: + +### A. Client Mobile App (The "Requester") +* **User:** Business Owners, Venue Managers. +* **Role:** The demand generator. It allows clients to request staff on the fly, track who is arriving, and approve hours worked. +* **Key Value:** Speed and visibility. A manager can fill a sudden "no-show" gap in seconds from their phone. + +### B. Staff Mobile App (The "Worker") +* **User:** Temporary Staff (Servers, Cooks, Bartenders). +* **Role:** The supply pool. It acts as their personal agency, handling job discovery, schedule management, and instant payouts. +* **Key Value:** Flexibility and financial security. Workers choose when they work and get paid faster. + +### C. Krow Web Application (The "HQ") +* **User:** Administrators, HR, Finance, and Client Executives. +* **Role:** The command center. It handles the heavy lifting—complex invoicing, vendor management, compliance audits, and strategic data analysis. +* **Key Value:** Control and insight. It turns operational data into cost-saving strategies. + +## 3. How the Applications Interact +The three applications do not "talk" directly to each other (e.g., the staff app doesn't send a message directly to the client app). Instead, they all communicate with a central **Shared Backend System** (The "Brain"). + +* **Scenario: Filling a Shift** + 1. **Client App:** Manager posts a shift for "Friday, 6 PM". + 2. **Backend:** Receives the request, validates it, and notifies eligible workers. + 3. **Staff App:** Worker sees the notification and taps "Accept". + 4. **Backend:** Confirms the match, updates the schedule, and alerts the client. + 5. **Web App:** Admin sees the shift status change from "Open" to "Filled" on the live dashboard. + +## 4. Shared Services & Infrastructure +To function as a cohesive unit, the ecosystem relies on several shared foundational services: + +* **Central Database:** The "Single Source of Truth." Whether a worker updates their profile photo on mobile or an admin updates it on the web, the change is saved in one place (Firebase/Firestore) and reflects everywhere instantly. +* **Authentication Service:** A unified login system. While users have different roles (Client vs. Staff), the security mechanism verifying their identity is shared. +* **Notification Engine:** A centralized service that knows how to reach users—sending push notifications to phones (Mobile Apps) and emails to desktops (Web App). +* **Payment Gateway:** A shared financial pipe. It collects money from clients (Credit Card/ACH) and disburses it to workers (Direct Deposit/Instant Pay). + +## 5. Data Ownership & Boundaries +To maintain privacy and organization, data is strictly compartmentalized: + +* **Worker Data:** Owned by the worker but accessible to the platform. Clients can only see limited details (Name, Rating, Skills) of workers assigned to *their* specific shifts. They cannot see a worker's full financial history or assignments with other clients. +* **Client Data:** Owned by the business. Workers see only what is necessary to do the job (Location, Dress Code, Supervisor Name). They cannot see the client's internal billing or strategic reports. +* **Platform Data:** owned by Krow (Admins). This includes the aggregate data used for "Smart Strategies" and market analysis—e.g., "Average hourly rate for a Bartender in downtown." + +## 6. Security & Access Control +The system operates on a **Role-Based Access Control (RBAC)** model: + +* **Authentication (Who are you?):** Strict verification using email/password or phone/OTP (One-Time Password). +* **Authorization (What can you do?):** + * **Staff:** Can *read* job details but *write* only to their own timesheets and profile. + * **Clients:** Can *write* new orders and *read* reports for their own venues only. + * **Admins:** Have "Super User" privileges to view and modify data across the entire system to resolve disputes or manage configurations. + +## 7. Inter-Application Dependencies +While the apps are installed separately, they are operationally dependent: + +* **Dependency A:** The **Client App** cannot function without the **Staff App** users. An order posted by a client is useless if no workers exist to claim it. +* **Dependency B:** The **Staff App** relies on the **Web App** for financial processing. A worker can "clock out," but they don't get paid until the backend logic (managed via Web App rules) processes the invoice. +* **Dependency C:** All apps depend on the **Backend API**. If the central server goes down, no app can fetch data, effectively pausing the entire operation. + +--- + +# System Overview Diagram +```mermaid +flowchart LR + subgraph Users [Users] + ClientUser((Client Manager)) + StaffUser((Temporary Worker)) + AdminUser((Admin / Ops)) + end + + subgraph FrontEnd [Application Ecosystem] + direction TB + ClientApp[Client Mobile App] + StaffApp[Staff Mobile App] + WebApp[Web Management Console] + end + + subgraph Backend [Shared Backend Services] + direction TB + APIGateway[API Gateway] + + subgraph CoreServices [Core Business Logic] + AuthService[Authentication Service] + OrderService[Order & Shift Service] + WorkerService[Worker Profile Service] + FinanceService[Billing & Payroll Service] + NotificationEngine[Notification Engine] + AnalyticsEngine[Analytics & AI Engine] + end + + Database[("Central Database (Firebase/Firestore)")] + end + + subgraph External [External Integrations] + PaymentProvider["Payment Gateway (Stripe/Bank)"] + MapService[Maps & Geolocation] + SMSGateway[SMS / OTP Service] + end + + %% User Interactions + ClientUser -- Uses --> ClientApp + StaffUser -- Uses --> StaffApp + AdminUser -- Uses --> WebApp + + %% App to Backend Communication + ClientApp -- "Auth, Orders, Timesheets" --> APIGateway + StaffApp -- "Auth, Job Claims, Clock-In" --> APIGateway + WebApp -- "Auth, Admin, Reports" --> APIGateway + + %% Internal Backend Flow + APIGateway --> CoreServices + CoreServices --> Database + + %% Specific Service Interactions + AuthService -- "Verifies Identity" --> Database + OrderService -- "Matches Shifts" --> Database + WorkerService -- "Stores Profiles" --> Database + FinanceService -- "Processes Invoices" --> Database + AnalyticsEngine -- "Reads Data" --> Database + + %% External Connections + AuthService -- "Sends OTP" --> SMSGateway + StaffApp -- "Verifies Location" --> MapService + FinanceService -- "Processes Payouts" --> PaymentProvider + NotificationEngine -- "Push Alerts" --> ClientApp + NotificationEngine -- "Push Alerts" --> StaffApp + + %% Styling + classDef user fill:#e1f5fe,stroke:#01579b,stroke-width:2px; + classDef app fill:#fff9c4,stroke:#fbc02d,stroke-width:2px; + classDef backend fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px; + classDef external fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px; + classDef db fill:#e0e0e0,stroke:#616161,stroke-width:2px; + + class ClientUser,StaffUser,AdminUser user; + class ClientApp,StaffApp,WebApp app; + class APIGateway,AuthService,OrderService,WorkerService,FinanceService,NotificationEngine,AnalyticsEngine backend; + class PaymentProvider,MapService,SMSGateway external; + class Database db; +``` diff --git a/docs/ARCHITECTURE/client-mobile-application/use-case.md b/docs/ARCHITECTURE/client-mobile-application/use-case.md new file mode 100644 index 00000000..9223f6e6 --- /dev/null +++ b/docs/ARCHITECTURE/client-mobile-application/use-case.md @@ -0,0 +1,209 @@ +# Client Application: Use Case Overview + +This document details the primary business actions and user flows within the **Client Mobile Application**. It is organized according to the application's logical structure and navigation flow. + +--- + +## 1. Application Access & Authentication + +### 1.1 Initial Startup & Auth Check +* **Actor:** Business Manager +* **Description:** The system determines if the user is already logged in or needs to authenticate. +* **Main Flow:** + 1. User opens the application. + 2. System checks for a valid session. + 3. If authenticated, user is directed to the **Home Dashboard**. + 4. If unauthenticated, user is directed to the **Get Started** screen. + +### 1.2 Register Business Account (Sign Up) +* **Actor:** New Business Manager +* **Description:** Creating a new identity for the business on the Krow platform. +* **Main Flow:** + 1. User taps "Sign Up". + 2. User enters company details (Name, Industry). + 3. User enters contact information and password. + 4. User confirms registration and is directed to the Main App. + +### 1.3 Business Sign In +* **Actor:** Existing Business Manager +* **Description:** Accessing an existing account. +* **Main Flow:** + 1. User enters registered email and password. + 2. System validates credentials. + 3. User is granted access to the dashboard. + +--- + +## 2. Order Management (Requesting Staff) + +### 2.1 Rapid Order (Urgent Needs) +* **Actor:** Business Manager +* **Description:** Posting a shift for immediate, same-day fulfillment. +* **Main Flow:** Taps "RAPID" -> Selects Role -> Sets Quantity -> Confirms ASAP status -> Posts Order. + +### 2.2 Scheduled Orders (Planned Needs) +* **Actor:** Business Manager +* **Description:** Planning for future staffing requirements. +* **Main Flow:** + 1. User selects "Create Order". + 2. User chooses the frequency: + * **One-Time:** A single specific shift. + * **Recurring:** Shifts that repeat on a schedule (e.g., every Monday). + * **Permanent:** Long-term staffing placement. + 3. User enters date, time, role, and location. + 4. User reviews cost and posts the order. + +--- + +## 3. Operations & Workforce Management + +### 3.1 Monitor Today's Coverage (Coverage Tab) +* **Actor:** Business Manager +* **Description:** A bird's-eye view of all shifts happening today. +* **Main Flow:** User navigates to "Coverage" tab -> Views percentage filled -> Identifies open gaps -> Re-posts unfilled shifts if necessary. + +### 3.2 Live Activity Tracking +* **Actor:** Business Manager +* **Description:** Real-time feed of worker clock-ins and status updates. +* **Location:** Home Tab / Coverage Detail. +* **Main Flow:** User monitors the live feed for worker arrivals and on-site status. + +### 3.3 Verify Worker Attire +* **Actor:** Business Manager / Site Supervisor +* **Description:** Ensuring staff arriving on-site meet the required dress code. +* **Main Flow:** User selects an active shift -> Selects worker -> Checks attire compliance (shoes, uniform, ID) -> Submits verification. + +### 3.4 Review & Approve Timesheets +* **Actor:** Business Manager +* **Description:** Finalizing hours worked for payroll processing. +* **Main Flow:** User navigates to "Timesheets" -> Reviews actual vs. scheduled hours -> Taps "Approve" or "Dispute". + +--- + +## 4. Reports & Analytics + +### 4.1 Business Intelligence Reporting +* **Actor:** Business Manager / Executive +* **Description:** Accessing data visualizations to optimize business operations. +* **Available Reports:** + * **Daily Ops:** Day-to-day fulfillment and performance. + * **Spend Report:** Financial breakdown of labor costs. + * **Forecast:** Projected staffing needs and costs. + * **Performance:** Worker and vendor reliability scores. + * **No-Show:** Tracking attendance issues. + * **Coverage:** Detailed fill-rate analysis. + +--- + +## 5. Billing & Administration + +### 5.1 Financial Management (Billing Tab) +* **Actor:** Business Manager / Finance Admin +* **Description:** Reviewing invoices and managing payment methods. +* **Main Flow:** User navigates to "Billing" -> Views current balance -> Downloads past invoices -> Updates credit card/ACH info. + +### 5.2 Manage Business Locations (Hubs) +* **Actor:** Business Manager +* **Description:** Defining different venues or branches where staff will be sent. +* **Main Flow:** User goes to Settings -> Client Hubs -> Adds/Edits location details and addresses. + +### 5.3 Profile & Settings Management +* **Actor:** Business Manager +* **Description:** Updating personal contact info and notification preferences. +* **Main Flow:** User goes to Settings -> Edits Profile -> Toggles notification settings for shift updates and billing alerts. + +--- + +# Use Case Diagram +```mermaid +flowchart TD + subgraph AppInitialization [App Initialization] + Start[Start App] --> CheckAuth{Check Auth Status} + CheckAuth -- Authenticated --> GoHome[Go to Main App] + CheckAuth -- Unauthenticated --> GetStarted[Go to Get Started] + end + + subgraph Authentication [Authentication] + GetStarted --> AuthChoice{Select Option} + AuthChoice -- Sign In --> SignIn[Sign In Screen] + AuthChoice -- Sign Up --> SignUp[Sign Up Screen] + + SignIn --> EnterCreds[Enter Credentials] + EnterCreds --> VerifySignIn{Verify} + VerifySignIn -- Success --> GoHome + VerifySignIn -- Failure --> SignInError[Show Error] + + SignUp --> EnterBusinessDetails[Enter Business Details] + EnterBusinessDetails --> CreateAccount[Create Account] + CreateAccount -- Success --> GoHome + end + + subgraph MainApp [Main Application Shell] + GoHome --> Shell[Scaffold with Nav Bar] + Shell --> TabNav{Tab Navigation} + + TabNav -- Index 0 --> Coverage[Coverage Tab] + TabNav -- Index 1 --> Billing[Billing Tab] + TabNav -- Index 2 --> Home[Home Tab] + TabNav -- Index 3 --> Orders[Orders/Shifts Tab] + TabNav -- Index 4 --> Reports[Reports Tab] + end + + subgraph HomeActions [Home Tab Actions] + Home --> CreateOrderAction{Create Order} + CreateOrderAction -- Rapid --> RapidOrder[Rapid Order Flow] + CreateOrderAction -- One-Time --> OneTimeOrder[One-Time Order Flow] + CreateOrderAction -- Recurring --> RecurringOrder[Recurring Order Flow] + CreateOrderAction -- Permanent --> PermanentOrder[Permanent Order Flow] + + Home --> QuickActions[Quick Actions Widget] + Home --> Settings[Go to Settings] + Home --> Hubs[Go to Client Hubs] + end + + subgraph OrderManagement [Order Management Flows] + RapidOrder --> SubmitRapid[Submit Rapid Order] + OneTimeOrder --> SubmitOneTime[Submit One-Time Order] + RecurringOrder --> SubmitRecurring[Submit Recurring Order] + PermanentOrder --> SubmitPermanent[Submit Permanent Order] + + SubmitRapid --> OrderSuccess[Order Success] + SubmitOneTime --> OrderSuccess + SubmitRecurring --> OrderSuccess + SubmitPermanent --> OrderSuccess + OrderSuccess --> Home + end + + subgraph ReportsAnalysis [Reports & Analytics] + Reports --> SelectReport{Select Report} + SelectReport -- Daily Ops --> DailyOps[Daily Ops Report] + SelectReport -- Spend --> SpendReport[Spend Report] + SelectReport -- Forecast --> ForecastReport[Forecast Report] + SelectReport -- Performance --> PerfReport[Performance Report] + SelectReport -- No-Show --> NoShowReport[No-Show Report] + SelectReport -- Coverage --> CovReport[Coverage Report] + end + + subgraph WorkforceMgmt [Workforce Management] + Orders --> ViewShifts[View Shifts List] + ViewShifts --> ShiftDetail[View Shift Detail] + ShiftDetail --> VerifyAttire[Verify Attire] + + Home --> ViewWorkers[View Workers List] + Home --> ViewTimesheets[View Timesheets] + ViewTimesheets --> ApproveTime[Approve Hours] + ViewTimesheets --> DisputeTime[Dispute Hours] + end + + subgraph SettingsFlow [Settings & Configuration] + Settings --> EditProfile[Edit Profile] + Settings --> ManageHubsLink[Manage Hubs] + Hubs --> AddHub[Add New Hub] + Hubs --> EditHub[Edit Existing Hub] + end + + %% Relationships across subgraphs + OrderSuccess -.-> Coverage + VerifySignIn -.-> Shell + CreateAccount -.-> Shell +``` \ No newline at end of file diff --git a/docs/ARCHITECTURE/staff-mobile-application/use-case.md b/docs/ARCHITECTURE/staff-mobile-application/use-case.md new file mode 100644 index 00000000..23b920b8 --- /dev/null +++ b/docs/ARCHITECTURE/staff-mobile-application/use-case.md @@ -0,0 +1,208 @@ +# Staff Application: Use Case Overview + +This document details the primary business actions available within the **Staff Mobile Application**. It is organized according to the application's logical structure and navigation flow. + +--- + +## 1. Application Access & Authentication + +### 1.1 App Initialization +* **Actor:** Temporary Worker +* **Description:** The system checks if the user is logged in upon startup. +* **Main Flow:** + 1. Worker opens the app. + 2. System checks for a valid auth token. + 3. If valid, worker goes to **Home**. + 4. If invalid, worker goes to **Get Started**. + +### 1.2 Onboarding & Registration +* **Actor:** New Worker +* **Description:** Creating a new profile to join the Krow network. +* **Main Flow:** + 1. Worker enters phone number. + 2. System sends SMS OTP. + 3. Worker verifies OTP. + 4. System checks if profile exists. + 5. If new, worker completes **Profile Setup Wizard** (Personal Info -> Role/Experience -> Attire Sizes). + 6. Worker enters the Main App. + +--- + +## 2. Job Discovery (Home Tab) + +### 2.1 Browse & Filter Jobs +* **Actor:** Temporary Worker +* **Description:** Finding suitable work opportunities. +* **Main Flow:** + 1. Worker taps "View Available Jobs". + 2. Worker filters by Role (e.g., Server) or Distance. + 3. Worker selects a job card to view details (Pay, Location, Requirements). + +### 2.2 Claim Open Shift +* **Actor:** Temporary Worker +* **Description:** Committing to work a specific shift. +* **Main Flow:** + 1. From Job Details, worker taps "Claim Shift". + 2. System validates eligibility (Certificates, Conflicts). + 3. If eligible, shift is added to "My Schedule". + 4. If missing requirements, system prompts to **Upload Compliance Docs**. + +### 2.3 Set Availability +* **Actor:** Temporary Worker +* **Description:** Defining working hours to get better job matches. +* **Main Flow:** Worker taps "Set Availability" -> Selects dates/times -> Saves preferences. + +--- + +## 3. Shift Execution (Shifts & Clock In Tabs) + +### 3.1 View Schedule +* **Actor:** Temporary Worker +* **Description:** Reviewing upcoming commitments. +* **Main Flow:** Navigate to "My Shifts" tab -> View list of claimed shifts. + +### 3.2 GPS-Verified Clock In +* **Actor:** Temporary Worker +* **Description:** Starting a shift once on-site. +* **Main Flow:** + 1. Navigate to "Clock In" tab. + 2. System checks GPS location against job site coordinates. + 3. If **On Site**, "Swipe to Clock In" becomes active. + 4. Worker swipes to start the timer. + 5. If **Off Site**, system displays an error message. + +### 3.3 Submit Timesheet +* **Actor:** Temporary Worker +* **Description:** Completing a shift and submitting hours for payment. +* **Main Flow:** + 1. Worker swipes to "Clock Out". + 2. Worker confirms total hours and break times. + 3. Worker submits timesheet for client approval. + +--- + +## 4. Financial Management (Payments Tab) + +### 4.1 Track Earnings +* **Actor:** Temporary Worker +* **Description:** Monitoring financial progress. +* **Main Flow:** Navigate to "Payments" -> View "Pending Pay" (unpaid) and "Total Earned" (paid). + +### 4.2 Request Early Pay +* **Actor:** Temporary Worker +* **Description:** Accessing wages before the standard payday. +* **Main Flow:** + 1. Tap "Request Early Pay". + 2. Select amount to withdraw from available balance. + 3. Confirm transfer fee. + 4. Funds are transferred to the linked bank account. + +--- + +## 5. Profile & Compliance (Profile Tab) + +### 5.1 Manage Compliance Documents +* **Actor:** Temporary Worker +* **Description:** Keeping certifications up to date. +* **Main Flow:** Navigate to "Compliance Menu" -> "Upload Certificates" -> Take photo of ID/License -> Submit. + +### 5.2 Manage Tax Forms +* **Actor:** Temporary Worker +* **Description:** Submitting legal employment forms. +* **Main Flow:** Navigate to "Tax Forms" -> Complete W-4 or I-9 digitally -> Sign and Submit. + +### 5.3 Krow University Training +* **Actor:** Temporary Worker +* **Description:** Improving skills to unlock better jobs. +* **Main Flow:** Navigate to "Krow University" -> Select Module -> Watch Video/Take Quiz -> Earn Badge. + +### 5.4 Account Settings +* **Actor:** Temporary Worker +* **Description:** Managing personal data. +* **Main Flow:** Update Bank Details, View Benefits, or Access Support/FAQs. + +--- + +# Use Cases Diagram +```mermaid +flowchart TD + subgraph AppInitialization [App Initialization] + Start[Start App] --> CheckAuth{Check Auth Status} + CheckAuth -- Authenticated --> GoHome[Go to Main App] + CheckAuth -- Unauthenticated --> GetStarted[Go to Get Started] + end + + subgraph Authentication [Onboarding & Authentication] + GetStarted --> InputPhone[Enter Phone Number] + InputPhone --> VerifyOTP[Verify SMS Code] + VerifyOTP --> CheckProfile{Profile Complete?} + CheckProfile -- Yes --> GoHome + CheckProfile -- No --> SetupProfile[Profile Setup Wizard] + + SetupProfile --> Step1[Personal Info] + Step1 --> Step2[Role & Experience] + Step2 --> Step3[Attire Sizes] + Step3 --> GoHome + end + + subgraph MainApp [Main Application Shell] + GoHome --> Shell[Scaffold with Nav Bar] + Shell --> TabNav{Tab Navigation} + + TabNav -- Index 0 --> Shifts[My Shifts Tab] + TabNav -- Index 1 --> Payments[Payments Tab] + TabNav -- Index 2 --> Home[Home Tab] + TabNav -- Index 3 --> ClockIn[Clock In Tab] + TabNav -- Index 4 --> Profile[Profile Tab] + end + + subgraph HomeAndDiscovery [Job Discovery] + Home --> ViewOpenJobs[View Available Jobs] + ViewOpenJobs --> FilterJobs[Filter by Role/Distance] + ViewOpenJobs --> JobDetail[View Job Details] + JobDetail --> ClaimShift{Claim Shift} + ClaimShift -- Success --> ShiftSuccess[Shift Added to Schedule] + ClaimShift -- "Missing Req" --> PromptUpload[Prompt Compliance Upload] + + Home --> SetAvailability[Set Availability] + Home --> ViewUpcoming[View Upcoming Shifts] + end + + subgraph ShiftExecution [Shift Execution] + Shifts --> ViewSchedule[View My Schedule] + ClockIn --> CheckLocation{Verify GPS Location} + CheckLocation -- "On Site" --> SwipeIn[Swipe to Clock In] + CheckLocation -- "Off Site" --> LocationError[Show Location Error] + + SwipeIn --> ActiveShift[Shift In Progress] + ActiveShift --> SwipeOut[Swipe to Clock Out] + SwipeOut --> ConfirmHours[Confirm Hours & Breaks] + ConfirmHours --> SubmitTimesheet[Submit Timesheet] + end + + subgraph Financials [Earnings & Payments] + Payments --> ViewEarnings[View Pending Earnings] + Payments --> ViewHistory[View Payment History] + ViewEarnings --> EarlyPay{Request Early Pay?} + EarlyPay -- Yes --> SelectAmount[Select Amount] + SelectAmount --> ConfirmTransfer[Confirm Transfer] + end + + subgraph ProfileAndCompliance [Profile & Compliance] + Profile --> ComplianceMenu[Compliance Menu] + ComplianceMenu --> UploadDocs[Upload Certificates] + ComplianceMenu --> TaxForms["Manage Tax Forms (W-4/I-9)"] + + Profile --> KrowUniversity[Krow University] + KrowUniversity --> StartTraining[Start Training Module] + + Profile --> BankAccount[Manage Bank Details] + Profile --> Benefits[View Benefits] + Profile --> Support["Access Support/FAQs"] + end + + %% Relationships across subgraphs + SubmitTimesheet -.-> ViewEarnings + PromptUpload -.-> ComplianceMenu + ShiftSuccess -.-> ViewSchedule +``` \ No newline at end of file diff --git a/docs/ARCHITECTURE/system-bible.md b/docs/ARCHITECTURE/system-bible.md new file mode 100644 index 00000000..bbf8e972 --- /dev/null +++ b/docs/ARCHITECTURE/system-bible.md @@ -0,0 +1,251 @@ +# The Krow Platform System Bible + +**Status:** Official / Living Document +**Version:** 1.0.0 + +--- + +## 1. Executive Summary + +### What the System Is +The **Krow Platform** is a multi-sided workforce management ecosystem that digitizes the entire lifecycle of temporary staffing. It replaces fragmented, manual processes (phone calls, spreadsheets, paper timesheets) with a unified digital infrastructure connecting businesses ("Clients") directly with temporary workers ("Staff"). + +### Why It Exists +The temporary staffing industry suffers from friction, lack of transparency, and delayed payments. Businesses struggle to find reliable staff quickly, while workers face uncertain schedules and slow wage access. Krow exists to remove this friction, ensuring shifts are filled instantly, work is verified accurately, and payments are processed swiftly. + +### Who It Serves +1. **Clients (Businesses):** Venue managers and owners who need on-demand or scheduled staff. +2. **Staff (Workers):** Individuals seeking flexible, temporary employment opportunities. +3. **Admins (Operations):** Internal teams managing the marketplace, compliance, and financial flows. + +### High-Level Value Proposition +Krow transforms labor from a manual logistical headache into a streamlined digital asset. For clients, it provides "staff on tap" with verified compliance. For workers, it offers "freedom and instant pay." For the platform operators, it delivers data-driven oversight of a complex marketplace. + +--- + +## 2. System Vision & Product Principles + +### Core Goals +1. **Immediacy:** Reduce the time-to-fill for urgent shifts from hours to minutes. +2. **Accuracy:** Eliminate payroll disputes through GPS-verified digital timesheets. +3. **Compliance:** Automate the enforcement of legal and safety requirements (attire, certifications). + +### Problems It Intentionally Solves +* **The "No-Show" Epidemic:** By creating a transparent marketplace with reliability ratings. +* **Payroll Latency:** By enabling "Early Pay" features rooted in verified digital time cards. +* **Administrative Bloat:** By automating invoice generation and worker onboarding. + +### Problems It Intentionally Does NOT Solve +* **Full-Time Recruitment:** This system is optimized for shift-based, temporary labor, not permanent headhunting. +* **Gig Economy "Tasking":** It focuses on professional hospitality and event roles, not general unskilled errands. + +### Guiding Product Principles +* **Mobile-First for Operations:** If a task happens on a job site (clocking in, checking coverage), it *must* be possible on a phone. +* **Data as the Truth:** We do not rely on verbal confirmations. If it isn't in the system (GPS stamp, digital signature), it didn't happen. +* **Separation of Concerns:** Clients manage demand; Staff manage supply; Admin manages the rules. These roles never blur. + +--- + +## 3. Ecosystem Overview + +The ecosystem comprises three distinct applications, each serving a specific user persona but operating on a shared reality. + +### 1. Client Mobile Application (The "Requester") +* **Platform:** Flutter (Mobile) +* **Responsibility:** Demand generation. Allows businesses to post orders, track arriving staff in real-time, and approve work hours. +* **Concept:** The "Remote Control" for the venue's staffing operations. + +### 2. Staff Mobile Application (The "Worker") +* **Platform:** Flutter (Mobile) +* **Responsibility:** Supply fulfillment. Empowering workers to find jobs, manage their schedule, verify their presence (Clock In), and access earnings. +* **Concept:** The worker's "Digital Agency" in their pocket. + +### 3. Krow Web Application (The "HQ") +* **Platform:** React (Web) +* **Responsibility:** Ecosystem governance. The command center for high-level analytics, complex financial operations (invoicing/payouts), vendor management, and system administration. +* **Concept:** The "Mission Control" for the business backend. + +--- + +## 4. System Architecture Overview + +The Krow Platform follows a **Service-Oriented Architecture (SOA)** where multiple front-end clients interface with a shared, monolithic logical backend (exposed via API Gateway). + +### Architectural Style +* **Centralized State:** A single backend database serves as the source of truth for all apps. +* **Role-Based Access:** The API exposes different endpoints and data views depending on the authenticated user's role (Client vs. Staff). +* **Event-Driven Flows:** Key actions (e.g., "Shift Posted") trigger downstream effects (e.g., "Push Notification Sent") across the ecosystem. + +### System Boundaries +The "System" encompasses the three front-end apps and the shared backend services. External boundaries are drawn at: +* **Payment Gateways:** We initiate transfers, but the actual money movement is external. +* **Maps/Geolocation:** We consume location data but do not maintain mapping infrastructure. +* **SMS/Identity:** We offload OTP delivery to specialized providers. + +### Trust Boundaries +* **Mobile Apps are Untrusted:** We assume any data coming from a client device (GPS coordinates, timestamps) could be manipulated and must be validated server-side. +* **Web App is Semi-Trusted:** Admin actions are logged for audit but are assumed to be authorized operations. + +```mermaid +flowchart TD + subgraph Clients [Client Layer] + CMA[Client Mobile App] + SMA[Staff Mobile App] + WEB[Web Admin Portal] + end + + subgraph API [Interface Layer] + Gateway[API Gateway] + end + + subgraph Services [Core Service Layer] + Auth[Identity Service] + Ops[Operations Service] + Finance[Financial Service] + end + + subgraph Data [Data Layer] + DB[(Central Database)] + end + + CMA --> Gateway + SMA --> Gateway + WEB --> Gateway + Gateway --> Services + Services --> DB +``` + +--- + +## 5. Application Responsibility Matrix + +| Feature Domain | Client App (Mobile) | Staff App (Mobile) | Web App (Admin/Ops) | +| :--- | :--- | :--- | :--- | +| **User Onboarding** | Register Business | Register Worker | Onboard Vendors | +| **Shift Management** | **Create** & Monitor | **Claim** & Execute | **Oversee** & Resolve | +| **Time Tracking** | Approve / Dispute | Clock In / Out | Audit Logs | +| **Finance** | Pay Invoices | Request Payout | Generate Bills | +| **Compliance** | Verify Attire | Upload Docs | Verify Docs | +| **Analytics** | View My Venue Stats | View My Earnings | Global Market Analysis | + +### Critical Rules +* **Client App MUST NOT** access worker financial data (bank details, total platform earnings). +* **Staff App MUST NOT** see client billing rates or internal venue notes. +* **Web App MUST** be the only place where global system configurations (e.g., platform fees) are changed. + +--- + +## 6. Use Cases + +The following are the **official system use cases**. Any feature request not mapping to one of these must be scrutinized. + +### A. Staffing Operations +1. **Post Urgent Shift (Client):** + * *Pre:* Valid client account. + * *Flow:* Select Role -> Set Qty -> Post. + * *Post:* Notification sent to eligible workers. +2. **Claim Shift (Staff):** + * *Pre:* Worker meets compliance reqs. + * *Flow:* View Job -> Accept. + * *Post:* Shift is locked; Client notified. +3. **Execute Shift (Staff):** + * *Pre:* On-site GPS verification. + * *Flow:* Clock In -> Work -> Clock Out. + * *Validation:* Location check passes. +4. **Approve Timesheet (Client):** + * *Pre:* Shift completed. + * *Flow:* Review Hours -> Approve. + * *Post:* Payment scheduled. + +### B. Financial Operations +5. **Process Billing (Web/Admin):** + * *Flow:* Aggregate approved hours -> Generate Invoice -> Charge Client. +6. **Request Early Pay (Staff):** + * *Pre:* Accrued unpaid earnings. + * *Flow:* Select Amount -> Confirm -> Transfer. + +### C. Governance +7. **Verify Compliance (Web/Admin):** + * *Flow:* Review uploaded ID -> Mark as Verified. + * *Post:* Worker eligible for shifts. +8. **Strategic Analysis (Web/Client):** + * *Flow:* View Savings Engine -> Adopt Recommendation. + +--- + +## 7. Cross-Application Interaction Rules + +### Communication Patterns +* **Indirect Communication:** Apps NEVER speak peer-to-peer. + * *Correct:* Client App posts order -> Backend -> Staff App receives notification. + * *Forbidden:* Client App sends data directly to Staff App via Bluetooth/LAN. +* **Push Notifications:** Used as the primary signal to "wake up" an app and fetch fresh data from the server. + +### Dependency Direction +* **Downstream Dependency:** The Mobile Apps depend on the Web App's configuration (e.g., if Admin adds a new "Role Type" on Web, it appears on Mobile). +* **Upstream Data Flow:** Operational data flows *up* from Mobile (Clock-ins) to Web (Reporting). + +### Anti-Patterns to Avoid +* **"Split Brain" Logic:** Business logic (e.g., "How is overtime calculated?") must live in the Backend, NOT duplicated in the mobile apps. +* **Local-Only State:** Critical data (shift status) must never exist only on a user's device. It must be synced immediately. + +--- + +## 8. Data Ownership & Source of Truth + +| Data Domain | Source of Truth | Write Access | Read Access | +| :--- | :--- | :--- | :--- | +| **User Identity** | Auth Service | User (Self), Admin | System-wide | +| **Shift Status** | Order Service | Client (Create), Staff (Update status) | All (Context dependent) | +| **Time Cards** | Database | Staff (Create), Client (Approve) | Admin, Payroll | +| **Compliance Docs** | Worker Profile | Staff (Upload), Admin (Verify) | Client (Status only) | +| **Platform Rates** | System Config | Admin | Read-only | + +### Consistency Principle +**"The Server is Right."** If a mobile device displays a shift as "Open" but the server says "Filled," the device is wrong and must refresh. We prioritize data integrity over offline availability for critical transaction states. + +--- + +## 9. Security & Access Model + +### User Roles +1. **Super Admin:** Full system access. +2. **Client Manager:** Access to own venue data only. +3. **Worker:** Access to own schedule and public job board only. + +### Authentication Philosophy +* **Zero Trust:** Every API request must carry a valid, unexpired token. +* **Session Management:** Mobile sessions are persistent (long-lived tokens) for usability; Web sessions (Admin) are shorter for security. + +### Authorization Boundaries +* **Horizontal Separation:** Client A cannot see Client B's orders. Worker A cannot see Worker B's pay. +* **Vertical Separation:** Staff cannot access Admin APIs. + +--- + +## 10. Non-Negotiables & Guardrails + +1. **No GPS, No Pay:** A clock-in event *must* have valid geolocation data attached. No exceptions. +2. **Compliance First:** A worker cannot claim a shift if their required documents (e.g., Food Handler Card) are expired. The system must block this at the API level. +3. **Immutable Audit Trail:** Once a timesheet is approved and paid, it cannot be deleted or modified, only reversed via a new corrective transaction. +4. **One Account Per Person:** Strict identity checks to prevent duplicate worker profiles. + +--- + +## 11. Known Constraints & Assumptions + +* **Connectivity:** The system assumes a reliable internet connection for critical actions (Claiming, Clocking In). Offline mode is limited to read-only views of cached schedules. +* **Device Capability:** We assume worker devices have functional GPS and Camera hardware. +* **Payment Timing:** "Instant" pay is subject to external banking network delays (ACH/RTP) and is not truly real-time in all cases. + +--- + +## 12. Glossary + +* **Shift:** A single unit of work with a start time, end time, and role. +* **Order:** A request from a client containing one or more shifts. +* **Clock-In:** The digital timestamp marking the start of work, verified by GPS. +* **Rapid Order:** A specific order type designed for immediate (<24h) fulfillment. +* **Early Pay:** A financial feature allowing workers to withdraw accrued wages before the standard pay period ends. +* **Hub:** A specific physical location or venue belonging to a Client. +* **Compliance:** The state of having all necessary legal and safety documents verified. diff --git a/docs/ARCHITECTURE/web-application/use-case.md b/docs/ARCHITECTURE/web-application/use-case.md new file mode 100644 index 00000000..a4f65c95 --- /dev/null +++ b/docs/ARCHITECTURE/web-application/use-case.md @@ -0,0 +1,170 @@ +# Web Application: Use Case Overview + +This document details the primary business actions and user flows within the **Krow Web Application**. It is organized according to the logical workflows for each primary user role as defined in the system's architecture. + +--- + +## 1. Access & Authentication (Common) + +### 1.1 Web Portal Login +* **Actor:** All Users (Admin, Client, Vendor) +* **Description:** Secure entry into the management console. +* **Main Flow:** + 1. User enters email and password on the login screen. + 2. System verifies credentials. + 3. System determines user role (Admin, Client, or Vendor). + 4. User is directed to their specific role-based dashboard. + +--- + +## 2. Admin Workflows (Operations Manager) + +### 2.1 Global Operational Oversight +* **Actor:** Admin +* **Description:** Monitoring the pulse of the entire platform. +* **Main Flow:** User accesses Admin Dashboard -> Views all active orders across all clients -> Monitors user registration trends. + +### 2.2 Marketplace & Vendor Management +* **Actor:** Admin +* **Description:** Expanding the platform's supply network. +* **Main Flow:** + 1. User navigates to Marketplace. + 2. User invites a new Vendor via email. + 3. User sets global default rates for roles. + 4. User audits vendor performance scores. + +### 2.3 System Administration +* **Actor:** Admin +* **Description:** Configuring platform-wide settings and security. +* **Main Flow:** User updates system configurations -> Reviews security audit logs -> Manages internal support tickets. + +--- + +## 3. Client Executive Workflows + +### 3.1 Strategic Insights (Savings Engine) +* **Actor:** Client Executive +* **Description:** Using AI to optimize labor spend. +* **Main Flow:** + 1. User opens the Savings Engine. + 2. User reviews identified cost-saving opportunities. + 3. User clicks "Approve Strategy" to implement recommendations (e.g., vendor consolidation). + +### 3.2 Finance & Billing Management +* **Actor:** Client Executive / Finance Admin +* **Description:** Managing corporate financial obligations. +* **Main Flow:** User views all pending invoices -> Downloads detailed line-item reports -> Processes payments to Krow. + +### 3.3 Operations Overview +* **Actor:** Client Executive +* **Description:** High-level monitoring of venue operations. +* **Main Flow:** User views a summary of their venue orders -> Reviews ratings of assigned staff -> Monitors fulfillment rates. + +--- + +## 4. Vendor Workflows (Staffing Agency) + +### 4.1 Vendor Operations (Order Fulfillment) +* **Actor:** Vendor Manager +* **Description:** Fulfilling client staffing requests. +* **Main Flow:** + 1. User views incoming shift requests. + 2. User selects a shift. + 3. User uses the **Worker Selection Tool** to assign the best-fit staff. + 4. User confirms assignment. + +### 4.2 Workforce Roster Management +* **Actor:** Vendor Manager +* **Description:** Maintaining their agency's supply of workers. +* **Main Flow:** User navigates to Roster -> Adds new workers -> Updates compliance documents and certifications -> Edits worker profiles. + +### 4.3 Vendor Finance +* **Actor:** Vendor Manager +* **Description:** Managing agency revenue and worker payouts. +* **Main Flow:** User views payout history -> Submits invoices for completed shifts -> Tracks pending payments from Krow. + +--- + +## 5. Shared Functional Modules + +### 5.1 Order Details & History +* **Actor:** All Roles +* **Description:** Accessing granular data for any specific staffing request. +* **Main Flow:** User clicks any order ID -> System displays shift times, roles, assigned staff, and audit history. + +### 5.2 Invoice Detail View +* **Actor:** Admin, Client, Vendor +* **Description:** Reviewing the breakdown of costs for a billing period. +* **Main Flow:** User opens an invoice -> System displays worker names, hours worked, bill rates, and total totals per role. + +--- + +# Use Case Diagram +```mermaid +flowchart TD + subgraph AccessControl [Access & Authentication] + Start[Start Web Portal] --> CheckSession{Check Session} + CheckSession -- Valid --> CheckRole{Check User Role} + CheckSession -- Invalid --> Login[Login Screen] + Login --> EnterCreds[Enter Credentials] + EnterCreds --> Verify{Verify} + Verify -- Success --> CheckRole + Verify -- Failure --> Error[Show Error] + + CheckRole -- Admin --> AdminDash[Admin Dashboard] + CheckRole -- Client --> ClientDash[Client Dashboard] + CheckRole -- Vendor --> VendorDash[Vendor Dashboard] + end + + subgraph AdminWorkflows [Admin Workflows] + AdminDash --> GlobalOversight[Global Oversight] + GlobalOversight --> ViewAllOrders[View All Orders] + GlobalOversight --> ViewAllUsers[View All Users] + + AdminDash --> MarketplaceMgmt[Marketplace Management] + MarketplaceMgmt --> OnboardVendor[Onboard Vendor] + MarketplaceMgmt --> ManageRates[Manage Global Rates] + + AdminDash --> SystemAdmin[System Administration] + SystemAdmin --> ConfigSettings[Configure Settings] + SystemAdmin --> AuditLogs[View Audit Logs] + end + + subgraph ClientWorkflows [Client Executive Workflows] + ClientDash --> ClientInsights[Strategic Insights] + ClientInsights --> SavingsEngine[Savings Engine] + SavingsEngine --> ViewOpp[View Opportunity] + ViewOpp --> ApproveStrategy[Approve Strategy] + + ClientDash --> ClientFinance[Finance & Billing] + ClientFinance --> ViewInvoices[View Invoices] + ClientFinance --> PayInvoice[Pay Invoice] + + ClientDash --> ClientOps[Operations Overview] + ClientOps --> ViewMyOrders[View My Orders] + ClientOps --> ViewMyStaff[View Assigned Staff] + end + + subgraph VendorWorkflows [Vendor Workflows] + VendorDash --> VendorOps[Vendor Operations] + VendorOps --> ViewRequests[View Shift Requests] + ViewRequests --> AssignWorker[Assign Worker] + VendorOps --> ManageRoster[Manage Worker Roster] + ManageRoster --> UpdateWorkerProfile[Update Worker Profile] + + VendorDash --> VendorFinance[Vendor Finance] + VendorFinance --> ViewPayouts[View Payouts] + VendorFinance --> SubmitInvoice[Submit Invoice] + end + + subgraph SharedModules [Shared Functional Modules] + ViewAllOrders -.-> OrderDetail[Order Details] + ViewMyOrders -.-> OrderDetail + ViewRequests -.-> OrderDetail + + AssignWorker -.-> WorkerSelection[Worker Selection Tool] + + ViewInvoices -.-> InvoiceDetail[Invoice Detail View] + SubmitInvoice -.-> InvoiceDetail + end +``` diff --git a/docs/DATACONNECT_GUIDES/backend_cloud_run_functions.md b/docs/DATACONNECT_GUIDES/backend_cloud_run_functions.md new file mode 100644 index 00000000..a8214808 --- /dev/null +++ b/docs/DATACONNECT_GUIDES/backend_cloud_run_functions.md @@ -0,0 +1,183 @@ +# Backend Cloud Run / Functions Guide + +## 1) Validate Shift Acceptance (Worker) +**Best fit:** Cloud Run + +**Why backend logic is required** +- Shift acceptance must be enforced server‑side to prevent bypassing the client. +- It must be race‑condition safe (two accepts at the same time). +- It needs to be extensible for future eligibility rules. + +**Proposed backend solution** +Add a single command endpoint: +- `POST /shifts/:shiftId/accept` + +**Backend flow** +- Verify Firebase Auth token + permissions (worker identity). +- Run an extensible validation pipeline (pluggable rules): + - `NoOverlapRule` (M4) + - Future rules can be added without changing core logic. +- Apply acceptance in a DB transaction (atomic). +- Return a clear error payload on rejection: + - `409 CONFLICT` (overlap) with `{ code, message, conflictingShiftIds }` + +--- + +## 2) Validate Shift Creation by a Client (Minimum Hours — soft check) +**Best fit:** Cloud Run + +**Why backend logic is required** +- Creation rules must be enforced server‑side so clients can’t bypass validations by skipping the UI or calling APIs directly. +- We want a scalable rule system so new creation checks can be added without rewriting core logic. + +**Proposed backend solution** +Add/route creation through a backend validation layer (Cloud Run endpoint or a dedicated “create order” command). + +**On create** +- Compute shift duration and compare against vendor minimum (current: **5 hours**). +- Return a consistent validation response when below minimum, e.g.: + - `200 OK` with `{ valid: false, severity: "SOFT", code: "MIN_HOURS", message, minHours: 5 }` + - (or `400` only if we decide it should block creation; for now it’s a soft check) + +**FE note** +- Show the same message before submission (UX feedback), but backend remains the source of truth. + +--- + +## 3) Enforce Cancellation Policy (no cancellations within 24 hours) +**Best fit:** Cloud Run + +**Why backend logic is required** +- Cancellation restrictions must be enforced server‑side to prevent policy bypass. +- Ensures consistent behavior across web/mobile and future clients. + +**Proposed backend solution** +Add a backend command endpoint for cancel: +- `POST /shifts/:shiftId/cancel` (or `/orders/:id/cancel` depending on ownership model) + +**Backend checks** +- If `now >= shiftStart - 24h`, reject cancellation. + +**Error response** +- `403 FORBIDDEN` (or `409 CONFLICT`) with `{ code: "CANCEL_WINDOW_LOCKED", message, windowHours: 24, penalty: }` +- Once penalty is finalized, include it in the response and logs/audit trail. + +--- + +## 4) Implement Worker Documentation Upload Process +**Best fit:** Cloud Functions v2 + Cloud Storage + +**Why backend logic is required** +- Uploads must be stored securely and reliably linked to the correct worker profile. +- Requires server‑side auth and auditing. + +**Proposed backend solution** +- HTTP/Callable Function: `uploadInit(workerId, docType)` → returns signed upload URL + `documentId`. +- Client uploads directly to Cloud Storage. +- Storage trigger (`onFinalize`) or `uploadComplete(documentId)`: + - Validate uploader identity/ownership. + - Store metadata in DB (type, path, status, timestamps). + - Link document to worker profile. +- Enforce access control (worker/self, admins, authorized client reviewers). + +--- + +## 5) Parse Uploaded Documentation for Verification +**Best fit:** Cloud Functions (event‑driven) or Cloud Run worker (async) + +**Why backend logic is required** +- Parsing should run asynchronously. +- Store structured results for review while keeping manual verification as the final authority. + +**Proposed backend solution** +- Trigger on Storage upload finalize: + - OCR/AI extract key fields → store structured output (`parsedFields`, `confidence`, `aiStatus`). +- Keep manual review: + - Client can approve/override AI results. + - Persist reviewer decision + audit trail. + +--- + +## 6) Support Attire Upload for Workers +**Best fit:** Cloud Functions v2 + Cloud Storage + +**Why backend logic is required** +- Attire images must be securely stored and linked to the correct worker profile. +- Requires server‑side authorization. + +**Proposed backend solution** +- HTTP/Callable Function: `attireUploadInit(workerId)` → signed upload URL + `attireAssetId`. +- Client uploads to Cloud Storage. +- Storage trigger (`onFinalize`) or `attireUploadComplete(attireAssetId)`: + - Validate identity/ownership. + - Store metadata and link to worker profile. + +--- + +## 7) Verify Attire Images Against Shift Dress Code +**Best fit:** Cloud Functions (trigger) or Cloud Run worker (async) + +**Why backend logic is required** +- Verification must be enforced server‑side. +- Must remain reviewable/overrideable by the client even if AI passes. + +**Proposed backend solution** +- Async verification triggered after upload or when tied to a shift: + - Evaluate dress code rules (and optional AI). + - Store results `{ status, reasons, evidence, confidence }`. +- Client can manually approve/override; audit every decision. + +--- + +## 8) Support Shifts Requiring “Awaiting Confirmation” Status +**Best fit:** Cloud Run (domain state transitions) + +**Why backend logic is required** +- State transitions must be enforced server‑side. +- Prevent invalid bookings and support future workflow rules. + +**Proposed backend solution** +- Add status flow: `AWAITING_CONFIRMATION → BOOKED/ACTIVE` (per lifecycle). +- Command endpoint: `POST /shifts/:id/confirm`. + +**Backend validates** +- Caller is the assigned worker. +- Shift is still eligible (not started/canceled/overlapped, etc.). +- Persist transition + audit event. + +--- + +## 9) Enable NFC‑Based Clock‑In and Clock‑Out +**Best fit:** Cloud Run (secure API) + optional Cloud Functions for downstream events + +**Why backend logic is required** +- Clock‑in/out is security‑sensitive and must be validated server‑side. +- Requires strong auditing and anti‑fraud checks. + +**Proposed backend solution** +API endpoints: +- `POST /attendance/clock-in` +- `POST /attendance/clock-out` + +**Validate** +- Firebase identity. +- NFC tag legitimacy (mapped to hub/location). +- Time window rules + prevent duplicates/inconsistent sequences. + +**Persist** +- Store immutable events + derived attendance record. +- Emit audit logs/alerts if needed. + +--- + +## 10) Update Recurring & Permanent Orders (Backend) +**Best fit:** Cloud Run + +**Why backend logic is required** +Updating a recurring or permanent order is not a single update. It may affect **N shifts** and **M shift roles**, and requires extra validations, such as: +- Prevent editing shifts that already started. +- Prevent removing or reducing roles with assigned staff. +- Control whether changes apply to future only, from a given date, or all. +- Ensure data consistency (all‑or‑nothing updates). + +These operations can take time and must be enforced server‑side, even if the client is bypassed. diff --git a/docs/MOBILE/00-agent-development-rules.md b/docs/MOBILE/00-agent-development-rules.md new file mode 100644 index 00000000..c7322cfc --- /dev/null +++ b/docs/MOBILE/00-agent-development-rules.md @@ -0,0 +1,135 @@ +# Agent Development Rules + +These rules are **NON-NEGOTIABLE**. They are designed to prevent architectural degradation by automated agents. + +## 1. File Creation & Structure + +1. **Feature-First Packaging**: + * **DO**: Create new features as independent packages in `apps/mobile/packages/features//`. + * **DO NOT**: Add features to `apps/mobile/packages/core` or existing apps directly. + * **DO NOT**: Create cross-feature or cross-app dependencies. +2. **Path Conventions**: + * Entities: `apps/mobile/packages/domain/lib/src/entities/.dart` + * Repositories (Interface): `apps/mobile/packages/features///lib/src/domain/repositories/_repository_interface.dart` + * Repositories (Impl): `apps/mobile/packages/features///lib/src/data/repositories_impl/_repository_impl.dart` + * Use Cases: `apps/mobile/packages/features///lib/src/application/_usecase.dart` + * BLoCs: `apps/mobile/packages/features///lib/src/presentation/blocs/_bloc.dart` + * Pages: `apps/mobile/packages/features///lib/src/presentation/pages/_page.dart` + * Widgets: `apps/mobile/packages/features///lib/src/presentation/widgets/_widget.dart` +3. **Barrel Files**: + * **DO**: Use `export` in `lib/.dart` only for public APIs. + * **DO NOT**: Export internal implementation details in the main package file. + +## 2. Naming Conventions + +Follow Dart standards strictly. + +| Type | Convention | Example | +| :--- | :--- | :--- | +| **Files** | `snake_case` | `user_profile_page.dart` | +| **Classes** | `PascalCase` | `UserProfilePage` | +| **Variables** | `camelCase` | `userProfile` | +| **Interfaces** | terminate with `Interface` | `AuthRepositoryInterface` | +| **Implementations** | terminate with `Impl` | `AuthRepositoryImpl` | + +## 3. Logic Placement (Strict Boundaries) + +* **Business Rules**: MUST reside in **Use Cases** (Domain/Feature Application layer). + * *Forbidden*: Placing business rules in BLoCs or Widgets. +* **State Logic**: MUST reside in **BLoCs** or **StatefulWidgets** (only for ephemeral UI state). + * *Forbidden*: `setState` in Pages for complex state management. + * **Recommendation**: Pages should be `StatelessWidget` with state delegated to BLoCs. +* **Data Transformation**: MUST reside in **Repositories** (Data Connect layer). + * *Forbidden*: Parsing JSON in the UI or Domain. + * **Pattern**: Repositories map Data Connect models to Domain entities. +* **Navigation Logic**: MUST reside in **Flutter Modular Routes**. + * *Forbidden*: `Navigator.push` with hardcoded widgets. + * **Pattern**: Use named routes via `Modular.to.navigate()`. +* **Session Management**: MUST reside in **DataConnectService** via **SessionHandlerMixin**. + * **Pattern**: Automatic token refresh, auth state listening, and role-based validation. + * **UI Reaction**: **SessionListener** widget wraps the entire app and responds to session state changes. + +## 4. Localization (core_localization) Integration + +All user-facing text MUST be localized using the centralized `core_localization` package: + +1. **String Management**: + * Define all user-facing strings in `apps/mobile/packages/core_localization/lib/src/l10n/` + * Use `slang` or similar i18n tooling for multi-language support + * Access strings in presentation layer via `context.strings.` +2. **BLoC Integration**: + * `LocaleBloc` manages the current locale state + * Apps import `core_localization.LocalizationModule()` in their module imports + * Wrap app with `BlocProvider()` to expose locale state globally +3. **Feature Usage**: + * Pages and widgets access localized strings: `Text(context.strings.buttonLabel)` + * Build methods receive `BuildContext` with access to current locale + * No hardcoded English strings in feature packages +4. **Error Messages**: + * Use `ErrorTranslator` from `core_localization` to map domain failures to user-friendly messages + * **Pattern**: Failures emitted from BLoCs are translated to localized strings in the UI + +## 5. Data Connect Integration Strategy + +All backend access is centralized through `DataConnectService` in `apps/mobile/packages/data_connect`: + +1. **Repository Interface First**: Define `abstract interface class RepositoryInterface` in the feature's `domain/repositories/` folder. +2. **Repository Implementation**: Implement the interface in `data/repositories_impl/` using `_service.run()` wrapper. + * **Pattern**: `await _service.run(() => connector.().execute())` + * **Benefit**: Automatic auth validation, token refresh, and error handling. +3. **Session Handling**: Use `DataConnectService.instance.initializeAuthListener(allowedRoles: [...])` in app main.dart. + * **Automatic**: Token refresh with 5-minute expiry threshold. + * **Retry Logic**: 3 attempts with exponential backoff (1s → 2s → 4s) before emitting error. + * **Role Validation**: Configurable per app (e.g., Staff: `['STAFF', 'BOTH']`, Client: `['CLIENT', 'BUSINESS', 'BOTH']`). +4. **Session State Management**: Wrap app with `SessionListener` widget to react to session changes. + * **Dialogs**: Shows session expired or error dialogs for user-facing feedback. + * **Navigation**: Routes to login on session loss, to home on authentication. + +## 5. Prototype Migration Rules + +You have access to `prototypes/` folders. When migrating code: + +1. **Extract Assets**: + * You MAY copy icons, images, and colors. But they should be tailored to the current design system. Do not change the colours and typgorahys in the design system. They are final. And you have to use these in the UI. + * When you matching colous and typography, from the POC match it with the design system and use the colors and typography from the design system. As mentioned in the `apps/mobile/docs/03-design-system-usage.md`. +2. **Extract Layouts**: You MAY copy `build` methods for UI structure. +3. **REJECT Architecture**: You MUST **NOT** copy the `GetX`, `Provider`, or `MVC` patterns often found in prototypes. Refactor immediately to **Bloc + Clean Architecture with Flutter Modular and Melos**. + +## 6. Handling Ambiguity + +If a user request is vague: + +1. **STOP**: Do not guess domain fields or workflows. +2. **ANALYZE**: + - For architecture related questions, refer to `apps/mobile/docs/01-architecture-principles.md` or existing code. + - For design system related questions, refer to `apps/mobile/docs/03-design-system-usage.md` or existing code. +3. **DOCUMENT**: If you must make an assumption to proceed, add a comment `// ASSUMPTION: ` and mention it in your final summary. +4. **ASK**: Prefer asking the user for clarification on business rules (e.g., "Should a 'Job' have a 'status'?"). + +## 7. Dependencies + +* **DO NOT** add 3rd party packages without checking `apps/mobile/packages/core` first. +* **DO NOT** add `firebase_auth` or `firebase_data_connect` to any Feature package. They belong in `data_connect` only. +* **Service Locator**: Use `DataConnectService.instance` for singleton access to backend operations. +* **Dependency Injection**: Use Flutter Modular for BLoC and UseCase injection in `Module.routes()`. + +## 8. Error Handling + +* **Domain Failures**: Define custom `Failure` classes in `domain/failures/`. +* **Data Connect Errors**: Map Data Connect exceptions to Domain failures in repositories. +* **User Feedback**: BLoCs emit error states; UI displays snackbars or dialogs. +* **Session Errors**: SessionListener automatically shows dialogs for session expiration/errors. + +## 9. Testing + +* **Unit Tests**: Test use cases and repositories with real implementations. +* **Widget Tests**: Use `WidgetTester` to test UI widgets and BLoCs. +* **Integration Tests**: Test full feature flows end-to-end with Data Connect. +* **Pattern**: Use dependency injection via Modular to swap implementations if needed for testing. + +## 10. Follow Clean Code Principles + +* Add doc comments to all classes and methods you create. +* Keep methods and classes focused and single-responsibility. +* Use meaningful variable names that reflect intent. +* Keep widget build methods concise; extract complex widgets to separate files. diff --git a/docs/MOBILE/01-architecture-principles.md b/docs/MOBILE/01-architecture-principles.md new file mode 100644 index 00000000..c24a8295 --- /dev/null +++ b/docs/MOBILE/01-architecture-principles.md @@ -0,0 +1,197 @@ +# KROW Architecture Principles + +This document is the **AUTHORITATIVE** source of truth for the KROW engineering architecture. +All agents and engineers must adhere strictly to these principles. Deviations are interpreted as errors. + +## 1. High-Level Architecture + +The KROW platform follows a strict **Clean Architecture** implementation within a **Melos Monorepo**. +Dependencies flow **inwards** towards the Domain. + +```mermaid +graph TD + subgraph "Apps (Entry Points)" + ClientApp["apps/mobile/apps/client"] + StaffApp["apps/mobile/apps/staff"] + end + + subgraph "Features" + ClientFeatures["apps/mobile/packages/features/client/*"] + StaffFeatures["apps/mobile/packages/features/staff/*"] + end + + subgraph "Services" + DataConnect["apps/mobile/packages/data_connect"] + DesignSystem["apps/mobile/packages/design_system"] + CoreLocalization["apps/mobile/packages/core_localization"] + end + + subgraph "Core Domain" + Domain["apps/mobile/packages/domain"] + Core["apps/mobile/packages/core"] + end + + %% Dependency Flow + ClientApp --> ClientFeatures & DataConnect & CoreLocalization + StaffApp --> StaffFeatures & DataConnect & CoreLocalization + + ClientFeatures & StaffFeatures --> Domain + ClientFeatures & StaffFeatures --> DesignSystem + ClientFeatures & StaffFeatures --> CoreLocalization + ClientFeatures & StaffFeatures --> Core + + DataConnect --> Domain + DataConnect --> Core + DesignSystem --> Core + CoreLocalization --> Core + Domain --> Core + + %% Strict Barriers + linkStyle default stroke-width:2px,fill:none,stroke:gray +``` + +## 2. Repository Structure & Package Roles + +### 2.1 Apps (`apps/mobile/apps/`) +- **Role**: Application entry points and Dependency Injection (DI) roots. +- **Responsibilities**: + - Initialize Flutter Modular. + - Assemble features into a navigation tree. + - Inject concrete implementations (from `data_connect`) into Feature packages. + - Configure environment-specific settings. +- **RESTRICTION**: NO business logic. NO UI widgets (except `App` and `Main`). + +### 2.2 Features (`apps/mobile/packages/features//`) +- **Role**: Vertical slices of user-facing functionality. +- **Internal Structure**: + - `domain/`: Feature-specific Use Cases and Repository Interfaces. + - `data/`: Repository Implementations. + - `presentation/`: + - Pages, BLoCs, Widgets. + - For performance make the pages as `StatelessWidget` and move the state management to the BLoC or `StatefulWidget` to an external separate widget file. +- **Responsibilities**: + - **Presentation**: UI Pages, Modular Routes. + - **State Management**: BLoCs / Cubits. + - **Application Logic**: Use Cases. +- **RESTRICTION**: Features MUST NOT import other features. Communication happens via shared domain events. + +### 2.3 Domain (`apps/mobile/packages/domain`) +- **Role**: The stable heart of the system. Pure Dart. +- **Responsibilities**: + - **Entities**: Immutable data models (Data Classes). + - **Failures**: Domain-specific error types. +- **RESTRICTION**: NO Flutter dependencies. NO `json_annotation`. NO package dependencies (except `equatable`). + +### 2.4 Data Connect (`apps/mobile/packages/data_connect`) +- **Role**: Interface Adapter for Backend Access (Datasource Layer). +- **Responsibilities**: + - Implement Firebase Data Connect connector and service layer. + - Map Domain Entities to/from Data Connect generated code. + - Handle Firebase exceptions and map to domain failures. + - Provide centralized `DataConnectService` with session management. + +### 2.5 Design System (`apps/mobile/packages/design_system`) +- **Role**: Visual language and component library. +- **Responsibilities**: + - UI components if needed. But mostly try to modify the theme file (apps/mobile/packages/design_system/lib/src/ui_theme.dart) so we can directly use the theme in the app, to use the default material widgets. + - If not possible, and if that specific widget is used in multiple features, then try to create a shared widget in the `apps/mobile/packages/design_system/widgets`. + - Theme definitions (Colors, Typography). + - Assets (Icons, Images). + - More details on how to use this package is available in the `apps/mobile/docs/03-design-system-usage.md`. +- **RESTRICTION**: + - CANNOT change colours or typography. + - Dumb widgets only. NO business logic. NO state management (Bloc). + - More details on how to use this package is available in the `apps/mobile/docs/03-design-system-usage.md`. + +### 2.6 Core Localization (`apps/mobile/packages/core_localization`) +- **Role**: Centralized language and localization management. +- **Responsibilities**: + - Define all user-facing strings in `l10n/` with i18n tooling support + - Provide `LocaleBloc` for reactive locale state management + - Export `TranslationProvider` for BuildContext-based string access + - Map domain failures to user-friendly localized error messages via `ErrorTranslator` +- **Feature Integration**: + - Features access strings via `context.strings.` in presentation layer + - BLoCs don't depend on localization; they emit domain failures + - Error translation happens in UI layer (pages/widgets) +- **App Integration**: + - Apps import `LocalizationModule()` in their module imports + - Apps wrap the material app with `BlocProvider()` and `TranslationProvider` + - Apps initialize `MaterialApp` with locale from `LocaleState` + +### 2.7 Core (`apps/mobile/packages/core`) +- **Role**: Cross-cutting concerns. +- **Responsibilities**: + - Extension methods. + - Logger configuration. + - Base classes for Use Cases or Result types (functional error handling). + +## 3. Dependency Direction & Boundaries + +1. **Domain Independence**: `apps/mobile/packages/domain` knows NOTHING about the outer world. It defines *what* needs to be done, not *how*. +2. **UI Agnosticism**: `apps/mobile/packages/features` depends on `apps/mobile/packages/design_system` for looks and `apps/mobile/packages/domain` for logic. It does NOT know about Firebase. +3. **Data Isolation**: `apps/mobile/packages/data_connect` depends on `apps/mobile/packages/domain` to know what interfaces to implement. It does NOT know about the UI. + +## 4. Data Connect Service & Session Management + +All backend access is unified through `DataConnectService` with integrated session management: + +### 4.1 Session Handler Mixin +- **Location**: `apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart` +- **Responsibilities**: + - Automatic token refresh (triggered when token <5 minutes to expiry) + - Firebase auth state listening + - Role-based access validation + - Session state stream emissions + - 3-attempt retry logic with exponential backoff on token validation failure +- **Key Method**: `initializeAuthListener(allowedRoles: [...])` - call once on app startup + +### 4.2 Session Listener Widget +- **Location**: `apps/mobile/apps//lib/src/widgets/session_listener.dart` +- **Responsibilities**: + - Wraps entire app to listen to session state changes + - Shows user-friendly dialogs for session expiration/errors + - Handles navigation on auth state changes +- **Pattern**: `SessionListener(child: AppWidget())` + +### 4.3 Repository Pattern with Data Connect +1. **Interface First**: Define `abstract interface class RepositoryInterface` in feature domain layer. +2. **Implementation**: Use `_service.run()` wrapper that automatically: + - Validates user is authenticated (if required) + - Ensures token is valid and refreshes if needed + - Executes the Data Connect query + - Handles exceptions and maps to domain failures +3. **Session Store Population**: On successful auth, session stores are populated: + - Staff: `StaffSessionStore.instance.setSession(StaffSession(...))` + - Client: `ClientSessionStore.instance.setSession(ClientSession(...))` +4. **Lazy Loading**: If session is null, fetch data via `getStaffById()` or `getBusinessById()` and update store. + +## 5. Feature Isolation & Cross-Feature Communication + +- **Zero Direct Imports**: `import 'package:feature_a/...'` is FORBIDDEN inside `package:feature_b`. + - Exception: Shared packages like `domain`, `core`, and `design_system` are always accessible. +- **Navigation**: Use named routes via Flutter Modular: + - **Pattern**: `Modular.to.navigate('route_name')` + - **Configuration**: Routes defined in `module.dart` files; constants in `paths.dart` +- **Data Sharing**: Features do not share state directly. Shared data accessed through: + - **Domain Repositories**: Centralized data sources (e.g., `AuthRepository`) + - **Session Stores**: `StaffSessionStore` and `ClientSessionStore` for app-wide user context + - **Event Streams**: If needed, via `DataConnectService` streams for reactive updates + +## 6. App-Specific Session Management + +Each app (`staff` and `client`) has different role requirements and session patterns: + +### 6.1 Staff App Session +- **Location**: `apps/mobile/apps/staff/lib/main.dart` +- **Initialization**: `DataConnectService.instance.initializeAuthListener(allowedRoles: ['STAFF', 'BOTH'])` +- **Session Store**: `StaffSessionStore` with `StaffSession(user: User, staff: Staff?, ownerId: String?)` +- **Lazy Loading**: `getStaffName()` fetches via `getStaffById()` if session null +- **Navigation**: On auth → `Modular.to.toStaffHome()`, on unauth → `Modular.to.toInitialPage()` + +### 6.2 Client App Session +- **Location**: `apps/mobile/apps/client/lib/main.dart` +- **Initialization**: `DataConnectService.instance.initializeAuthListener(allowedRoles: ['CLIENT', 'BUSINESS', 'BOTH'])` +- **Session Store**: `ClientSessionStore` with `ClientSession(user: User, business: ClientBusinessSession?)` +- **Lazy Loading**: `getUserSessionData()` fetches via `getBusinessById()` if session null +- **Navigation**: On auth → `Modular.to.toClientHome()`, on unauth → `Modular.to.toInitialPage()` diff --git a/docs/MOBILE/02-design-system-usage.md b/docs/MOBILE/02-design-system-usage.md new file mode 100644 index 00000000..eeab7c90 --- /dev/null +++ b/docs/MOBILE/02-design-system-usage.md @@ -0,0 +1,155 @@ +# 03 - Design System Usage Guide + +This document defines the mandatory standards for designing and implementing user interfaces across all applications and feature packages using the shared `apps/mobile/packages/design_system`. + +## 1. Introduction & Purpose + +The Design System is the single source of truth for the visual identity of the project. Its purpose is to ensure UI consistency, reduce development velocity by providing reusable primitives, and eliminate "design drift" across multiple feature teams and applications. + +**All UI implementation MUST consume values ONLY from the `design_system` package.** + +### Core Principle +Design tokens (colors, typography, spacing, etc.) are immutable and defined centrally. Features consume these tokens but NEVER modify them. The design system maintains visual coherence across staff and client apps. + +## 2. Design System Ownership & Responsibility + +- **Centralized Authority**: The `apps/mobile/packages/design_system` is the owner of all brand assets, colors, typography, and core components. +- **No Local Overrides**: Feature packages (e.g., `staff_authentication`) are consumers. They are prohibited from defining their own global styles or overriding theme values locally. +- **Extension Policy**: If a required style (color, font, or icon) is missing, the developer must first add it to the `design_system` package following existing patterns before using it in a feature. + +## 3. Package Structure Overview (`apps/mobile/packages/design_system`) + +The package is organized to separate tokens from implementation: +- `lib/src/ui_colors.dart`: Color tokens and semantic mappings. +- `lib/src/ui_typography.dart`: Text styles and font configurations. +- `lib/src/ui_icons.dart`: Exported icon sets. +- `lib/src/ui_constants.dart`: Spacing, radius, and elevation tokens. +- `lib/src/ui_theme.dart`: Centralized `ThemeData` factory. +- `lib/src/widgets/`: Common "Smart Widgets" and reusable UI building blocks. + +## 4. Colors Usage Rules + +Feature packages **MUST NOT** define custom hex codes or `Color` constants. + +### Usage Protocol +- **Primary Method**:Use `UiColors` from the design system for specific brand accents. +- **Naming Matching**: If an exact color is missing, use the closest existing semantic color (e.g., use `UiColors.mutedForeground` instead of a hardcoded grey). + +```dart +// ❌ ANTI-PATTERN: Hardcoded color +Container(color: Color(0xFF1A2234)) + +// ✅ CORRECT: Design system token +Container(color: UiColors.background) +``` + +## 5. Typography Usage Rules + +Custom `TextStyle` definitions in feature packages are **STRICTLY PROHIBITED**. + +### Usage Protocol +- Use `UiTypography` from the design system for specific brand accents. + +```dart +// ❌ ANTI-PATTERN: Custom TextStyle +Text('Hello', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)) + +// ✅ CORRECT: Design system typography +Text('Hello', style: UiTypography.display1m) +``` + +## 6. Icons Usage Rules + +Feature packages **MUST NOT** import icon libraries (like `lucide_icons`) directly. They should use the icons exposed via `UiIcons`. + +- **Standardization**: Ensure the same icon is used for the same action across all features (e.g., always use `UiIcons.chevronLeft` for navigation). +- **Additions**: New icons must be added to the design system (only using the typedef _IconLib = LucideIcons or typedef _IconLib2 = FontAwesomeIcons; and nothing else) first to ensure they follow the project's stroke weight and sizing standards. + +## 7. UI Constants & Layout Rules + +Hardcoded padding, margins, and radius values are **PROHIBITED**. + +- **Spacing**: Use `UiConstants.spacing` multiplied by tokens (e.g., `S`, `M`, `L`). +- **Border Radius**: Use `UiConstants.borderRadius`. +- **Elevation**: Use `UiConstants.elevation`. + +```dart +// ✅ CORRECT: Spacing and Radius constants +Padding( + padding: EdgeInsets.all(UiConstants.spacingL), + child: Container( + borderRadius: BorderRadius.circular(UiConstants.radiusM), + ), +) +``` + +## 8. Common Smart Widgets Guidelines + +The design system provides "Smart Widgets" – these are high-level UI components that encapsulate both styling and standard behavior. + +- **Standard Widgets**: Prefer standard Flutter Material widgets (e.g., `ElevatedButton`) but styled via the central theme. +- **Custom Components**: Use `design_system` widgets for non-standard elements or wisgets that has similar design across various features, if provided. +- **Composition**: Prefer composing standard widgets over creating deep inheritance hierarchies in features. + +## 9. Theme Configuration & Usage + +Applications (`apps/mobile/apps/`) must initialize the theme once in the root `MaterialApp`. + +```dart +MaterialApp.router( + theme: StaffTheme.light, // Mandatory: Consumption of centralized theme + // ... +) +``` +**No application-level theme customization is allowed.** + +## 10. Feature Development Workflow (POC → Themed) + +To bridge the gap between rapid prototyping (POCs) and production-grade code, developers must follow this three-step workflow: + +1. **Step 1: Structural Implementation**: Implement the UI logic and layout **exactly matching the POC**. Hardcoded values from the POC are acceptable in this transient state to ensure visual parity. +2. **Step 2: Architecture Refactor**: Immediately refactor the code to: + - Follow clean architecture principles from `apps/mobile/docs/00-agent-development-rules.md` and `01-architecture-principles.md` + - Move business logic from widgets to BLoCs and use cases + - Implement proper repository pattern with Data Connect + - Use dependency injection via Flutter Modular +3. **Step 3: Design System Integration**: Immediately refactor UI to consume design system primitives: + - Replace hex codes with `UiColors` + - Replace manual `TextStyle` with `UiTypography` + - Replace hardcoded padding/radius with `UiConstants` + - Upgrade icons to design system versions + - Use `ThemeData` from `design_system` instead of local theme overrides + +## 11. Anti-Patterns & Common Mistakes + +- **"Magic Numbers"**: Hardcoding `EdgeInsets.all(12.0)` instead of using design system constants. +- **Local Themes**: Using `Theme(data: ...)` to override colors for a specific section of a page. +- **Hex Hunting**: Copy-pasting hex codes from Figma or POCs into feature code. +- **Package Bypassing**: Importing `package:flutter/material.dart` and ignoring `package:design_system`. +- **Stateful Pages**: Pages with complex state logic instead of delegating to BLoCs. +- **Direct Data Queries**: Features querying Data Connect directly instead of through repositories. +- **Global State**: Using global variables for session/auth instead of `SessionStore` + `SessionListener`. +- **Hardcoded Routes**: Using `Navigator.push(context, MaterialPageRoute(...))` instead of Modular. +- **Feature Coupling**: Importing one feature package from another instead of using domain-level interfaces. + +## 12. Enforcement & Review Checklist + +Before any UI code is merged, it must pass this checklist: + +### Design System Compliance +1. [ ] No hardcoded `Color(...)` or `0xFF...` in the feature package. +2. [ ] No custom `TextStyle(...)` definitions. +3. [ ] All spacing/padding/radius uses `UiConstants`. +4. [ ] All icons are consumed from the approved design system source. +5. [ ] The feature relies on the global `ThemeData` and does not provide local overrides. +6. [ ] The layout matches the POC visual intent while using design system primitives. + +### Architecture Compliance +7. [ ] No direct Data Connect queries in widgets; all data access via repositories. +8. [ ] BLoCs handle all non-trivial state logic; pages are mostly stateless. +9. [ ] Session/auth accessed via `SessionStore` not global state. +10. [ ] Navigation uses Flutter Modular named routes. +11. [ ] Features don't import other feature packages directly. +12. [ ] All business logic in use cases, not BLoCs or widgets. +13. [ ] Repositories properly implement error handling and mapping. +14. [ ] Doc comments present on all public classes and methods. diff --git a/internal/launchpad/assets/documents/prototype/client-mobile-application/architecture.md b/internal/launchpad/assets/documents/prototype/client-mobile-application/architecture.md index f035e224..174a0540 100644 --- a/internal/launchpad/assets/documents/prototype/client-mobile-application/architecture.md +++ b/internal/launchpad/assets/documents/prototype/client-mobile-application/architecture.md @@ -59,7 +59,7 @@ The application is broken down into several key functional modules: | Component | Primary Responsibility | Example Task | | :--- | :--- | :--- | -| **Router (GoRouter)** | Navigation traffic cop | Directs the user from the "Login" screen to the "Home" dashboard upon success. | +| **Router (Flutter Modular)** | Navigation traffic cop | Directs the user from the "Login" screen to the "Home" dashboard upon success. | | **Screens (UI)** | Displaying information | Renders the "Create Order" form and captures the user's input for date and time. | | **Providers (Riverpod)** | Data management & State | Holds the list of today's active shifts so multiple screens can access it without reloading. | | **Widgets** | Reusable UI building blocks | A "Shift Card" widget that displays shift details effectively, used in multiple lists throughout the app. | @@ -91,7 +91,7 @@ While currently operating as a high-fidelity prototype with mock data, the archi ## 8. Key Design Decisions * **Flutter Framework:** chosen for its ability to produce high-performance, native-feeling apps for both iOS and Android from a single codebase, reducing development time and cost. -* **GoRouter for Navigation:** A modern routing package that handles complex navigation scenarios (like deep linking and sub-routes) which are essential for a multi-layered app like this. +* **Flutter Modular for Navigation:** A modern routing package that handles complex navigation scenarios (like deep linking and sub-routes) which are essential for a multi-layered app like this. * **Riverpod for State Management:** A robust solution that catches programming errors at compile-time (while writing code) rather than run-time (while using the app), increasing app stability. * **Mock Data Services:** The decision to use extensive mock data allows for rapid UI/UX iteration and testing of business flows without waiting for the full backend infrastructure to be built. @@ -102,7 +102,7 @@ flowchart TD direction TB subgraph PresentationLayer["Presentation Layer (UI)"] direction TB - Router["GoRouter Navigation"] + Router["Flutter Modular Navigation"] subgraph FeatureModules["Feature Modules"] AuthUI["Auth Screens"] DashUI["Dashboard & Home"] diff --git a/internal/launchpad/assets/documents/prototype/staff-mobile-application/architecture.md b/internal/launchpad/assets/documents/prototype/staff-mobile-application/architecture.md index 0c2ffbff..07c385b7 100644 --- a/internal/launchpad/assets/documents/prototype/staff-mobile-application/architecture.md +++ b/internal/launchpad/assets/documents/prototype/staff-mobile-application/architecture.md @@ -98,7 +98,7 @@ flowchart TD direction TB subgraph PresentationLayer["Presentation Layer (UI)"] direction TB - Router["GoRouter Navigation"] + Router["Flutter Modular Navigation"] subgraph FeatureModules["Feature Modules"] AuthUI["Auth & Onboarding"] MarketUI["Marketplace & Jobs"] diff --git a/internal/launchpad/package-lock.json b/internal/launchpad/package-lock.json new file mode 100644 index 00000000..86416bc9 --- /dev/null +++ b/internal/launchpad/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "launchpad", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}