diff --git a/.agent/settings.local.json b/.agent/settings.local.json new file mode 100644 index 00000000..ca2f132d --- /dev/null +++ b/.agent/settings.local.json @@ -0,0 +1,23 @@ +{ + "permissions": { + "allow": [ + "Bash(grep:*)", + "mcp__paper__get_basic_info", + "mcp__paper__get_screenshot", + "mcp__paper__get_tree_summary", + "mcp__paper__update_styles", + "mcp__paper__set_text_content", + "mcp__paper__get_computed_styles", + "mcp__paper__finish_working_on_nodes", + "mcp__paper__get_font_family_info", + "mcp__paper__rename_nodes", + "mcp__paper__write_html", + "mcp__paper__get_children", + "mcp__paper__create_artboard", + "mcp__paper__delete_nodes", + "mcp__paper__get_jsx", + "mcp__paper__get_node_info", + "mcp__paper__duplicate_nodes" + ] + } +} diff --git a/.agent/skills/krow-mobile-architecture/SKILL.md b/.agent/skills/krow-mobile-architecture/SKILL.md new file mode 100644 index 00000000..eccc0bb2 --- /dev/null +++ b/.agent/skills/krow-mobile-architecture/SKILL.md @@ -0,0 +1,900 @@ +--- +name: krow-mobile-architecture +description: KROW mobile app Clean Architecture implementation including package structure, dependency rules, feature isolation, BLoC lifecycle management, session handling, and Data Connect connectors pattern. Use this when architecting new mobile features, debugging state management issues, preventing prop drilling, managing BLoC disposal, implementing session stores, or setting up connector repositories. Essential for maintaining architectural integrity across staff and client apps. +--- + +# KROW Mobile Architecture + +This skill defines the authoritative mobile architecture for the KROW platform. All code must strictly adhere to these principles to prevent architectural degradation. + +## When to Use This Skill + +- Architecting new mobile features +- Debugging state management or BLoC lifecycle issues +- Preventing prop drilling in UI code +- Managing session state and authentication +- Implementing Data Connect connector repositories +- Setting up feature modules and dependency injection +- Understanding package boundaries and dependencies +- Refactoring legacy code to Clean Architecture + +## 1. High-Level Architecture + +KROW follows **Clean Architecture** in a **Melos Monorepo**. Dependencies flow **inward** toward the Domain. + +``` +┌─────────────────────────────────────────────────────────┐ +│ Apps (Entry Points) │ +│ • apps/mobile/apps/client │ +│ • apps/mobile/apps/staff │ +│ Role: DI roots, navigation assembly, env config │ +└─────────────────┬───────────────────────────────────────┘ + │ depends on +┌─────────────────▼───────────────────────────────────────┐ +│ Features (Vertical Slices) │ +│ • apps/mobile/packages/features/client/* │ +│ • apps/mobile/packages/features/staff/* │ +│ Role: Pages, BLoCs, Use Cases, Feature Repositories │ +└─────┬───────────────────────────────────────┬───────────┘ + │ depends on │ depends on +┌─────▼────────────────┐ ┌───────▼───────────┐ +│ Design System │ │ Core Localization│ +│ • UI components │ │ • LocaleBloc │ +│ • Theme/colors │ │ • Translations │ +│ • Typography │ │ • ErrorTranslator│ +└──────────────────────┘ └───────────────────┘ + │ both depend on +┌─────────────────▼───────────────────────────────────────┐ +│ Services (Interface Adapters) │ +│ • data_connect: Backend integration, session mgmt │ +│ • core: Extensions, base classes, utilities │ +└─────────────────┬───────────────────────────────────────┘ + │ both depend on +┌─────────────────▼───────────────────────────────────────┐ +│ Domain (Stable Core) │ +│ • Entities (immutable data models) │ +│ • Failures (domain-specific errors) │ +│ • Pure Dart only, zero Flutter dependencies │ +└─────────────────────────────────────────────────────────┘ +``` + +**Critical Rule:** Dependencies point INWARD only. Domain knows nothing about the outer layers. + +## 2. Package Structure & Responsibilities + +### 2.1 Apps (`apps/mobile/apps/`) + +**Role:** Application entry points and DI roots + +**Responsibilities:** +- Initialize Flutter Modular +- Assemble features into navigation tree +- Inject concrete implementations (from `data_connect`) into features +- Configure environment-specific settings (dev/stage/prod) +- Initialize session management + +**Structure:** +``` +apps/mobile/apps/staff/ +├── lib/ +│ ├── main.dart # Entry point, session initialization +│ ├── app_module.dart # Root module, imports features +│ ├── app_widget.dart # MaterialApp setup +│ └── src/ +│ ├── navigation/ # Typed navigators +│ └── widgets/ # SessionListener wrapper +└── pubspec.yaml +``` + +**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:** +``` +features/staff/profile/ +├── lib/ +│ ├── src/ +│ │ ├── domain/ +│ │ │ ├── repositories/ # Repository interfaces +│ │ │ │ └── profile_repository_interface.dart +│ │ │ └── usecases/ # Application logic +│ │ │ └── get_profile_usecase.dart +│ │ ├── data/ +│ │ │ └── repositories_impl/ # Repository concrete classes +│ │ │ └── profile_repository_impl.dart +│ │ └── presentation/ +│ │ ├── blocs/ # State management +│ │ │ └── profile_cubit.dart +│ │ ├── pages/ # Screens (StatelessWidget preferred) +│ │ │ └── profile_page.dart +│ │ └── widgets/ # Reusable UI components +│ │ └── profile_header.dart +│ └── profile_feature.dart # Barrel file (public API only) +└── pubspec.yaml +``` + +**Key Principles:** +- **Presentation:** UI Pages and Widgets, BLoCs/Cubits for state +- **Application:** Use Cases (business logic orchestration) +- **Data:** Repository implementations (backend integration) +- **Pages as StatelessWidget:** Move state to BLoCs for better performance and testability + +**RESTRICTION:** Features MUST NOT import other features. Communication happens via: +- Shared domain entities +- Session stores (`StaffSessionStore`, `ClientSessionStore`) +- Navigation via Modular +- Data Connect connector repositories + +### 2.3 Domain (`apps/mobile/packages/domain`) + +**Role:** The stable, pure heart of the system + +**Responsibilities:** +- Define **Entities** (immutable data models using Data Classes or Freezed) +- Define **Failures** (domain-specific error types) + +**Structure:** +``` +domain/ +├── lib/ +│ └── src/ +│ ├── entities/ +│ │ ├── user.dart +│ │ ├── staff.dart +│ │ └── shift.dart +│ └── failures/ +│ ├── failure.dart # Base class +│ ├── auth_failure.dart +│ └── network_failure.dart +└── pubspec.yaml +``` + +**Example Entity:** +```dart +import 'package:equatable/equatable.dart'; + +class Staff extends Equatable { + final String id; + final String name; + final String email; + final StaffStatus status; + + const Staff({ + required this.id, + required this.name, + required this.email, + required this.status, + }); + + @override + List get props => [id, name, email, status]; +} +``` + +**RESTRICTION:** +- NO Flutter dependencies (no `import 'package:flutter/material.dart'`) +- NO `json_annotation` or serialization code +- Only `equatable` for value equality +- Pure Dart only + +### 2.4 Data Connect (`apps/mobile/packages/data_connect`) + +**Role:** Interface Adapter for Backend Access + +**Responsibilities:** +- Centralized connector repositories (see Data Connect Connectors Pattern section) +- Implement Firebase Data Connect service layer +- Map Domain Entities ↔ Data Connect generated code +- Handle Firebase exceptions → domain failures +- Provide `DataConnectService` with session management + +**Structure:** +``` +data_connect/ +├── lib/ +│ ├── src/ +│ │ ├── services/ +│ │ │ ├── data_connect_service.dart # Core service +│ │ │ └── mixins/ +│ │ │ └── session_handler_mixin.dart +│ │ ├── connectors/ # Connector pattern (see below) +│ │ │ ├── staff/ +│ │ │ │ ├── domain/ +│ │ │ │ │ ├── repositories/ +│ │ │ │ │ │ └── staff_connector_repository.dart +│ │ │ │ │ └── usecases/ +│ │ │ │ │ └── get_profile_completion_usecase.dart +│ │ │ │ └── data/ +│ │ │ │ └── repositories/ +│ │ │ │ └── staff_connector_repository_impl.dart +│ │ │ ├── order/ +│ │ │ └── shifts/ +│ │ └── session/ +│ │ ├── staff_session_store.dart +│ │ └── client_session_store.dart +│ └── krow_data_connect.dart # Exports +└── pubspec.yaml +``` + +**RESTRICTION:** +- NO feature-specific logic +- Connectors are domain-neutral and reusable +- All queries follow Clean Architecture (domain interfaces → data implementations) + +### 2.5 Design System (`apps/mobile/packages/design_system`) + +**Role:** Visual language and component library + +**Responsibilities:** +- Theme definitions (`UiColors`, `UiTypography`) +- UI constants (`spacingL`, `radiusM`, etc.) +- Shared widgets (if reused across multiple features) +- Assets (icons, images, fonts) + +**Structure:** +``` +design_system/ +├── lib/ +│ └── src/ +│ ├── ui_colors.dart +│ ├── ui_typography.dart +│ ├── ui_icons.dart +│ ├── ui_constants.dart +│ ├── ui_theme.dart # ThemeData factory +│ └── widgets/ # Shared UI components +│ └── custom_button.dart +└── assets/ + ├── icons/ + └── images/ +``` + +**RESTRICTION:** +- Dumb widgets ONLY (no state management) +- NO business logic +- Colors and typography are IMMUTABLE (no feature can override) + +### 2.6 Core Localization (`apps/mobile/packages/core_localization`) + +**Role:** Centralized i18n management + +**Responsibilities:** +- Define all user-facing strings in `l10n/` +- Provide `LocaleBloc` for locale state management +- Export `TranslationProvider` for `context.strings` access +- Map domain failures to localized error messages via `ErrorTranslator` + +**Feature Integration:** +```dart +// Features access strings +Text(context.strings.loginButton) + +// BLoCs emit domain failures (not strings) +emit(AuthError(InvalidCredentialsFailure())); + +// UI translates failures to localized messages +final message = ErrorTranslator.translate(failure, context.strings); +``` + +**App Setup:** +```dart +// App imports LocalizationModule +class AppModule extends Module { + @override + List get imports => [LocalizationModule()]; +} + +// Wrap app with providers +BlocProvider( + create: (_) => Modular.get(), + child: TranslationProvider( + child: MaterialApp.router(...), + ), +) +``` + +### 2.7 Core (`apps/mobile/packages/core`) + +**Role:** Cross-cutting concerns + +**Responsibilities:** +- Extension methods (NavigationExtensions, ListExtensions, etc.) +- Base classes (UseCase, Failure, BlocErrorHandler) +- Logger configuration +- Result types for functional error handling + +## 3. Dependency Direction Rules + +1. **Domain Independence:** `domain` knows NOTHING about outer layers + - Defines *what* needs to be done, not *how* + - Pure Dart, zero Flutter dependencies + - Stable contracts that rarely change + +2. **UI Agnosticism:** Features depend on `design_system` for UI and `domain` for logic + - Features do NOT know about Firebase or backend details + - Backend changes don't affect feature implementation + +3. **Data Isolation:** `data_connect` depends on `domain` to know interfaces + - Implements domain repository interfaces + - Maps backend models to domain entities + - Does NOT know about UI + +**Dependency Flow:** +``` +Apps → Features → Design System + → Core Localization + → Data Connect → Domain + → Core +``` + +## 4. Data Connect Service & 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 <5 minutes to expiry) +- Firebase auth state listening +- Role-based access validation +- Session state stream emissions +- 3-attempt retry with exponential backoff (1s → 2s → 4s) + +**Key Method:** +```dart +// Call once on app startup +DataConnectService.instance.initializeAuthListener( + allowedRoles: ['STAFF', 'BOTH'], // or ['CLIENT', 'BUSINESS', 'BOTH'] +); +``` + +### 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 + +**Usage:** +```dart +// main.dart +runApp( + SessionListener( // ← Critical wrapper + child: ModularApp(module: AppModule(), child: AppWidget()), + ), +); +``` + +### 4.3 Repository Pattern with Data Connect + +**Step 1:** Define interface in feature domain: +```dart +// features/staff/profile/lib/src/domain/repositories/ +abstract interface class ProfileRepositoryInterface { + Future getProfile(String id); +} +``` + +**Step 2:** Implement using `DataConnectService.run()`: +```dart +// features/staff/profile/lib/src/data/repositories_impl/ +class ProfileRepositoryImpl implements ProfileRepositoryInterface { + final DataConnectService _service = DataConnectService.instance; + + @override + Future getProfile(String id) async { + return await _service.run(() async { + final response = await _service.connector + .getStaffById(id: id) + .execute(); + return _mapToStaff(response.data.staff); + }); + } +} +``` + +**Benefits of `_service.run()`:** +- ✅ Auto validates user is authenticated +- ✅ Refreshes token if <5 min to expiry +- ✅ Executes the query +- ✅ 3-attempt retry with exponential backoff +- ✅ Maps exceptions to domain failures + +### 4.4 Session Store Pattern + +After successful auth, populate session stores: + +**Staff App:** +```dart +StaffSessionStore.instance.setSession( + StaffSession( + user: user, + staff: staff, + ownerId: ownerId, + ), +); +``` + +**Client App:** +```dart +ClientSessionStore.instance.setSession( + ClientSession( + user: user, + business: business, + ), +); +``` + +**Lazy Loading:** If session is null, fetch from backend and update: +```dart +final session = StaffSessionStore.instance.session; +if (session?.staff == null) { + final staff = await getStaffById(session!.user.uid); + StaffSessionStore.instance.setSession( + session.copyWith(staff: staff), + ); +} +``` + +## 5. Feature Isolation & Communication + +### Zero Direct Imports + +```dart +// ❌ FORBIDDEN +import 'package:staff_profile/staff_profile.dart'; // in another feature + +// ✅ ALLOWED +import 'package:krow_domain/krow_domain.dart'; // shared domain +import 'package:krow_core/krow_core.dart'; // shared utilities +import 'package:design_system/design_system.dart'; // shared UI +``` + +### Navigation: Typed Navigators with Safe Extensions + +**Safe Navigation Extensions** (from `core` package): +```dart +extension NavigationExtensions on IModularNavigator { + /// Safely navigate with fallback to home + Future safeNavigate(String route) async { + try { + await navigate(route); + } catch (e) { + await navigate('/home'); // Fallback + } + } + + /// Safely push with fallback to home + Future safePush(String route) async { + try { + return await pushNamed(route); + } catch (e) { + await navigate('/home'); + return null; + } + } + + /// Safely pop with guard against empty stack + void popSafe() { + if (canPop()) { + pop(); + } else { + navigate('/home'); + } + } +} +``` + +**Typed Navigators:** +```dart +// apps/mobile/apps/staff/lib/src/navigation/staff_navigator.dart +extension StaffNavigator on IModularNavigator { + Future toStaffHome() => safeNavigate(StaffPaths.home); + + Future toShiftDetails(String shiftId) => + safePush('${StaffPaths.shifts}/$shiftId'); + + Future toProfileEdit() => safePush(StaffPaths.profileEdit); +} +``` + +**Usage in Features:** +```dart +// ✅ CORRECT +Modular.to.toStaffHome(); +Modular.to.toShiftDetails(shiftId: '123'); +Modular.to.popSafe(); + +// ❌ AVOID +Modular.to.navigate('/home'); // No safety +Navigator.push(...); // No Modular integration +``` + +### Data Sharing Patterns + +Features don't share state directly. Use: + +1. **Domain Repositories:** Centralized data sources +2. **Session Stores:** `StaffSessionStore`, `ClientSessionStore` for app-wide context +3. **Event Streams:** If needed, via `DataConnectService` streams +4. **Navigation Arguments:** Pass IDs, not full objects + +## 6. App-Specific Session Management + +### Staff App + +```dart +// main.dart +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + DataConnectService.instance.initializeAuthListener( + allowedRoles: ['STAFF', 'BOTH'], + ); + + runApp( + SessionListener( + child: ModularApp(module: StaffAppModule(), child: StaffApp()), + ), + ); +} +``` + +**Session Store:** `StaffSessionStore` +- Fields: `user`, `staff`, `ownerId` +- Lazy load: `getStaffById()` if staff is null + +**Navigation:** +- Authenticated → `Modular.to.toStaffHome()` +- Unauthenticated → `Modular.to.toInitialPage()` + +### Client App + +```dart +// main.dart +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + DataConnectService.instance.initializeAuthListener( + allowedRoles: ['CLIENT', 'BUSINESS', 'BOTH'], + ); + + runApp( + SessionListener( + child: ModularApp(module: ClientAppModule(), child: ClientApp()), + ), + ); +} +``` + +**Session Store:** `ClientSessionStore` +- Fields: `user`, `business` +- Lazy load: `getBusinessById()` if business is null + +**Navigation:** +- Authenticated → `Modular.to.toClientHome()` +- Unauthenticated → `Modular.to.toInitialPage()` + +## 7. Data Connect Connectors Pattern + +**Problem:** Without connectors, each feature duplicates backend queries. + +**Solution:** Centralize all backend queries in `data_connect/connectors/`. + +### Structure + +Mirror backend connector structure: + +``` +data_connect/lib/src/connectors/ +├── staff/ +│ ├── domain/ +│ │ ├── repositories/ +│ │ │ └── staff_connector_repository.dart # Interface +│ │ └── usecases/ +│ │ └── get_profile_completion_usecase.dart +│ └── data/ +│ └── repositories/ +│ └── staff_connector_repository_impl.dart # Implementation +├── order/ +├── shifts/ +└── user/ +``` + +**Maps to backend:** +``` +backend/dataconnect/connector/ +├── staff/ +├── order/ +├── shifts/ +└── user/ +``` + +### Clean Architecture in Connectors + +**Domain Interface:** +```dart +// staff_connector_repository.dart +abstract interface class StaffConnectorRepository { + Future getProfileCompletion(); + Future getStaffById(String id); +} +``` + +**Use Case:** +```dart +// get_profile_completion_usecase.dart +class GetProfileCompletionUseCase { + final StaffConnectorRepository _repository; + + GetProfileCompletionUseCase({required StaffConnectorRepository repository}) + : _repository = repository; + + Future call() => _repository.getProfileCompletion(); +} +``` + +**Data Implementation:** +```dart +// staff_connector_repository_impl.dart +class StaffConnectorRepositoryImpl implements StaffConnectorRepository { + final DataConnectService _service; + + @override + Future getProfileCompletion() async { + return _service.run(() async { + final staffId = await _service.getStaffId(); + final response = await _service.connector + .getStaffProfileCompletion(id: staffId) + .execute(); + + return _isProfileComplete(response); + }); + } +} +``` + +### Feature Integration + +**Step 1:** Feature registers connector repository: +```dart +// staff_main_module.dart +class StaffMainModule extends Module { + @override + void binds(Injector i) { + i.addLazySingleton( + StaffConnectorRepositoryImpl.new, + ); + + i.addLazySingleton( + () => GetProfileCompletionUseCase( + repository: i.get(), + ), + ); + + i.addLazySingleton( + () => StaffMainCubit( + getProfileCompletionUsecase: i.get(), + ), + ); + } +} +``` + +**Step 2:** BLoC uses it: +```dart +class StaffMainCubit extends Cubit { + final GetProfileCompletionUseCase _getProfileCompletionUsecase; + + Future loadProfileCompletion() async { + final isComplete = await _getProfileCompletionUsecase(); + emit(state.copyWith(isProfileComplete: isComplete)); + } +} +``` + +### Benefits + +✅ **No Duplication** - Query implemented once, used by many features +✅ **Single Source of Truth** - Backend change → update one place +✅ **Reusability** - Any feature can use any connector +✅ **Testability** - Mock connector repo to test features +✅ **Scalability** - Easy to add connectors as backend grows + +## 8. Avoiding Prop Drilling: Direct BLoC Access + +### The Problem + +Passing data through intermediate widgets creates maintenance burden: + +```dart +// ❌ BAD: Prop drilling +ProfilePage(status: status) + → ProfileHeader(status: status) + → ProfileLevelBadge(status: status) // Only widget that needs it +``` + +### The Solution: BlocBuilder in Leaf Widgets + +```dart +// ✅ GOOD: Direct BLoC access +class ProfileLevelBadge extends StatelessWidget { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.profile == null) return const SizedBox.shrink(); + + final level = _mapStatusToLevel(state.profile!.status); + return LevelBadgeUI(level: level); + }, + ); + } +} +``` + +### Guidelines + +1. **Leaf Widgets Access BLoC:** Widgets needing specific data should use `BlocBuilder` +2. **Container Widgets Stay Simple:** Parent widgets only manage layout +3. **No Unnecessary Props:** Don't pass data to intermediate widgets +4. **Single Responsibility:** Each widget has one reason to exist + +**Decision Tree:** +``` +Does this widget need data? +├─ YES, leaf widget → Use BlocBuilder +├─ YES, container → Use BlocBuilder in child +└─ NO → Don't add prop +``` + +## 9. BLoC Lifecycle & State Emission Safety + +### The Problem: StateError After Dispose + +When async operations complete after BLoC is closed: +``` +StateError: Cannot emit new states after calling close +``` + +**Root Causes:** +1. Transient BLoCs created with `BlocProvider(create:)` → disposed prematurely +2. Multiple BlocProviders disposing same singleton +3. User navigates away during async operation + +### The Solution: Singleton BLoCs + Safe Emit + +#### Step 1: Register as Singleton + +```dart +// ✅ GOOD: Singleton registration +i.addLazySingleton( + () => ProfileCubit(useCase1, useCase2), +); + +// ❌ BAD: Creates new instance each time +i.add(ProfileCubit.new); +``` + +#### Step 2: Use BlocProvider.value() + +```dart +// ✅ GOOD: Reuse singleton +final cubit = Modular.get(); +BlocProvider.value( + value: cubit, + child: MyWidget(), +) + +// ❌ BAD: Creates duplicate +BlocProvider( + create: (_) => Modular.get(), + child: MyWidget(), +) +``` + +#### Step 3: Safe Emit with BlocErrorHandler + +**Location:** `apps/mobile/packages/core/lib/src/presentation/mixins/bloc_error_handler.dart` + +```dart +mixin BlocErrorHandler on Cubit { + void _safeEmit(void Function(S) emit, S state) { + try { + emit(state); + } on StateError catch (e) { + developer.log( + 'Could not emit state: ${e.message}. Bloc may have been disposed.', + name: runtimeType.toString(), + ); + } + } +} +``` + +**Usage:** +```dart +class ProfileCubit extends Cubit with BlocErrorHandler { + Future loadProfile() async { + emit(state.copyWith(status: ProfileStatus.loading)); + + await handleError( + emit: emit, + action: () async { + final profile = await getProfile(); + emit(state.copyWith(status: ProfileStatus.loaded, profile: profile)); + // ✅ Safe even if BLoC disposed + }, + onError: (errorKey) => state.copyWith(status: ProfileStatus.error), + ); + } +} +``` + +### Pattern Summary + +| Pattern | When to Use | Risk | +|---------|------------|------| +| Singleton + BlocProvider.value() | Long-lived features | Low | +| Transient + BlocProvider(create:) | Temporary widgets | Medium | +| Direct BlocBuilder | Leaf widgets | Low | + +## 10. Anti-Patterns to Avoid + +❌ **Feature imports feature** +```dart +import 'package:staff_profile/staff_profile.dart'; // in another feature +``` + +❌ **Business logic in BLoC** +```dart +on((event, emit) { + if (event.email.isEmpty) { // ← Use case responsibility + emit(AuthError('Email required')); + } +}); +``` + +❌ **Direct Data Connect in features** +```dart +final response = await FirebaseDataConnect.instance.query(); // ← Use repository +``` + +❌ **Global state variables** +```dart +User? currentUser; // ← Use SessionStore +``` + +❌ **Direct Navigator.push** +```dart +Navigator.push(context, MaterialPageRoute(...)); // ← Use Modular +``` + +❌ **Hardcoded navigation** +```dart +Modular.to.navigate('/profile'); // ← Use safe extensions +``` + +## Summary + +The architecture enforces: +- **Clean Architecture** with strict layer boundaries +- **Feature Isolation** via zero cross-feature imports +- **Session Management** via DataConnectService and SessionListener +- **Connector Pattern** for reusable backend queries +- **BLoC Lifecycle** safety with singletons and safe emit +- **Navigation Safety** with typed navigators and fallbacks + +When implementing features: +1. Follow package structure strictly +2. Use connector repositories for backend access +3. Register BLoCs as singletons with `.value()` +4. Use safe navigation extensions +5. Avoid prop drilling with direct BLoC access +6. Keep domain pure and stable + +Architecture is not negotiable. When in doubt, refer to existing well-structured features or ask for clarification. diff --git a/.agent/skills/krow-mobile-design-system/SKILL.md b/.agent/skills/krow-mobile-design-system/SKILL.md new file mode 100644 index 00000000..2f6d6a40 --- /dev/null +++ b/.agent/skills/krow-mobile-design-system/SKILL.md @@ -0,0 +1,717 @@ +--- +name: krow-mobile-design-system +description: KROW mobile design system usage rules covering colors, typography, icons, spacing, and UI component patterns. Use this when implementing UI in KROW mobile features, matching POC designs to production, creating themed widgets, enforcing visual consistency, or reviewing UI code compliance. Prevents hardcoded values and ensures brand consistency across staff and client apps. Critical for maintaining immutable design tokens. +--- + +# KROW Mobile Design System Usage + +This skill defines mandatory standards for UI implementation using the shared `apps/mobile/packages/design_system`. All UI must consume design system tokens exclusively. + +## When to Use This Skill + +- Implementing any UI in mobile features +- Migrating POC/prototype designs to production +- Creating new themed widgets or components +- Reviewing UI code for design system compliance +- Matching colors and typography from designs +- Adding icons, spacing, or layout elements +- Setting up theme configuration in apps +- Refactoring UI code with hardcoded values + +## Core Principle + +**Design tokens (colors, typography, spacing) are IMMUTABLE and defined centrally.** + +Features consume tokens but NEVER modify them. The design system maintains visual coherence across all apps. + +## 1. Design System Ownership + +### Centralized Authority + +- `apps/mobile/packages/design_system` owns: + - All brand assets + - Colors and semantic color mappings + - Typography and font configurations + - Core UI components + - Icons and images + - Spacing, radius, elevation constants + +### No Local Overrides + +**✅ CORRECT:** +```dart +// Feature uses design system +import 'package:design_system/design_system.dart'; + +Container( + color: UiColors.background, + padding: EdgeInsets.all(UiConstants.spacingL), + child: Text( + 'Hello', + style: UiTypography.display1m, + ), +) +``` + +**❌ FORBIDDEN:** +```dart +// ❌ Custom colors in feature +const myBlue = Color(0xFF1A2234); + +// ❌ Custom text styles in feature +const myStyle = TextStyle(fontSize: 24, fontWeight: FontWeight.bold); + +// ❌ Theme overrides in feature +Theme( + data: ThemeData(primaryColor: Colors.blue), + child: MyWidget(), +) +``` + +### Extension Policy + +If a required style is missing: +1. **FIRST:** Add it to `design_system` following existing patterns +2. **THEN:** Use it in your feature + +**DO NOT** create temporary workarounds with hardcoded values. + +## 2. Package Structure + +``` +apps/mobile/packages/design_system/ +├── lib/ +│ ├── src/ +│ │ ├── ui_colors.dart # Color tokens +│ │ ├── ui_typography.dart # Text styles +│ │ ├── ui_icons.dart # Icon exports +│ │ ├── ui_constants.dart # Spacing, radius, elevation +│ │ ├── ui_theme.dart # ThemeData factory +│ │ └── widgets/ # Shared UI components +│ │ ├── custom_button.dart +│ │ └── custom_app_bar.dart +│ └── design_system.dart # Public exports +├── assets/ +│ ├── icons/ +│ ├── images/ +│ └── fonts/ +└── pubspec.yaml +``` + +## 3. Colors Usage Rules + +### Strict Protocol + +**✅ DO:** +```dart +// Use UiColors for all color needs +Container(color: UiColors.background) +Text('Hello', style: TextStyle(color: UiColors.foreground)) +Icon(Icons.home, color: UiColors.primary) +``` + +**❌ DON'T:** +```dart +// ❌ Hardcoded hex colors +Container(color: Color(0xFF1A2234)) + +// ❌ Material color constants +Container(color: Colors.blue) + +// ❌ Opacity on hardcoded colors +Container(color: Color(0xFF1A2234).withOpacity(0.5)) +``` + +### Available Color Categories + +**Brand Colors:** +- `UiColors.primary` - Main brand color +- `UiColors.secondary` - Secondary brand color +- `UiColors.accent` - Accent highlights + +**Semantic Colors:** +- `UiColors.background` - Page background +- `UiColors.foreground` - Primary text color +- `UiColors.card` - Card/container background +- `UiColors.border` - Border colors +- `UiColors.mutedForeground` - Secondary text + +**Status Colors:** +- `UiColors.success` - Success states +- `UiColors.warning` - Warning states +- `UiColors.error` - Error states +- `UiColors.info` - Information states + +### Color Matching from POCs + +When migrating POC designs: + +1. **Find closest match** in `UiColors` +2. **Use existing color** even if slightly different +3. **DO NOT add new colors** without design team approval + +**Example Process:** +```dart +// POC has: Color(0xFF2C3E50) +// Find closest: UiColors.background or UiColors.card +// Use: UiColors.card + +// POC has: Color(0xFF27AE60) +// Find closest: UiColors.success +// Use: UiColors.success +``` + +### Theme Access + +Colors can also be accessed via theme: +```dart +// Both are valid: +Container(color: UiColors.primary) +Container(color: Theme.of(context).colorScheme.primary) +``` + +## 4. Typography Usage Rules + +### Strict Protocol + +**✅ DO:** +```dart +// Use UiTypography for all text +Text('Title', style: UiTypography.display1m) +Text('Body', style: UiTypography.body1r) +Text('Label', style: UiTypography.caption1m) +``` + +**❌ DON'T:** +```dart +// ❌ Custom TextStyle +Text('Title', style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, +)) + +// ❌ Manual font configuration +Text('Body', style: TextStyle( + fontFamily: 'Inter', + fontSize: 16, +)) + +// ❌ Modifying existing styles inline +Text('Title', style: UiTypography.display1m.copyWith( + fontSize: 28, // ← Don't override size +)) +``` + +### Available Typography Styles + +**Display Styles (Large Headers):** +- `UiTypography.display1m` - Display Medium +- `UiTypography.display1sb` - Display Semi-Bold +- `UiTypography.display1b` - Display Bold + +**Heading Styles:** +- `UiTypography.heading1m` - H1 Medium +- `UiTypography.heading1sb` - H1 Semi-Bold +- `UiTypography.heading1b` - H1 Bold +- `UiTypography.heading2m` - H2 Medium +- `UiTypography.heading2sb` - H2 Semi-Bold + +**Body Styles:** +- `UiTypography.body1r` - Body Regular +- `UiTypography.body1m` - Body Medium +- `UiTypography.body1sb` - Body Semi-Bold +- `UiTypography.body2r` - Body 2 Regular + +**Caption/Label Styles:** +- `UiTypography.caption1m` - Caption Medium +- `UiTypography.caption1sb` - Caption Semi-Bold +- `UiTypography.label1m` - Label Medium + +### Allowed Customizations + +**✅ ALLOWED (Color Only):** +```dart +// You MAY change color +Text( + 'Title', + style: UiTypography.display1m.copyWith( + color: UiColors.error, // ← OK + ), +) +``` + +**❌ FORBIDDEN (Size, Weight, Family):** +```dart +// ❌ Don't change size +Text( + 'Title', + style: UiTypography.display1m.copyWith(fontSize: 28), +) + +// ❌ Don't change weight +Text( + 'Title', + style: UiTypography.display1m.copyWith(fontWeight: FontWeight.w900), +) + +// ❌ Don't change family +Text( + 'Title', + style: UiTypography.display1m.copyWith(fontFamily: 'Roboto'), +) +``` + +### Typography Matching from POCs + +When migrating: +1. Identify text role (heading, body, caption) +2. Find closest matching style in `UiTypography` +3. Use existing style even if size/weight differs slightly + +## 5. Icons Usage Rules + +### Strict Protocol + +**✅ DO:** +```dart +// Use UiIcons +Icon(UiIcons.home) +Icon(UiIcons.profile) +Icon(UiIcons.chevronLeft) +``` + +**❌ DON'T:** +```dart +// ❌ Direct icon library imports +import 'package:lucide_icons/lucide_icons.dart'; +Icon(LucideIcons.home) + +// ❌ Font Awesome direct +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +FaIcon(FontAwesomeIcons.house) +``` + +### Why Centralize Icons? + +1. **Consistency:** Same icon for same action everywhere +2. **Branding:** Unified icon set with consistent stroke weight +3. **Swappability:** Change icon library in one place + +### Icon Libraries + +Design system uses: +- `typedef _IconLib = LucideIcons;` (primary) +- `typedef _IconLib2 = FontAwesomeIcons;` (secondary) + +**Features MUST NOT import these directly.** + +### Adding New Icons + +If icon missing: +1. Add to `ui_icons.dart`: +```dart +class UiIcons { + static const home = _IconLib.home; + static const newIcon = _IconLib.newIcon; // Add here +} +``` +2. Use in feature: +```dart +Icon(UiIcons.newIcon) +``` + +## 6. Spacing & Layout Constants + +### Strict Protocol + +**✅ DO:** +```dart +// Use UiConstants for spacing +Padding(padding: EdgeInsets.all(UiConstants.spacingL)) +SizedBox(height: UiConstants.spacingM) +Container( + padding: EdgeInsets.symmetric( + horizontal: UiConstants.spacingL, + vertical: UiConstants.spacingM, + ), +) + +// Use UiConstants for radius +Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(UiConstants.radiusM), + ), +) + +// Use UiConstants for elevation +elevation: UiConstants.elevationLow +``` + +**❌ DON'T:** +```dart +// ❌ Magic numbers +Padding(padding: EdgeInsets.all(16.0)) +SizedBox(height: 24.0) +BorderRadius.circular(8.0) +elevation: 2.0 +``` + +### Available Constants + +**Spacing:** +```dart +UiConstants.spacingXs // Extra small +UiConstants.spacingS // Small +UiConstants.spacingM // Medium +UiConstants.spacingL // Large +UiConstants.spacingXl // Extra large +UiConstants.spacing2xl // 2x Extra large +``` + +**Border Radius:** +```dart +UiConstants.radiusS // Small +UiConstants.radiusM // Medium +UiConstants.radiusL // Large +UiConstants.radiusXl // Extra large +UiConstants.radiusFull // Fully rounded +``` + +**Elevation:** +```dart +UiConstants.elevationNone +UiConstants.elevationLow +UiConstants.elevationMedium +UiConstants.elevationHigh +``` + +## 7. Smart Widgets Usage + +### When to Use + +- **Prefer standard Flutter Material widgets** styled via theme +- **Use design system widgets** for non-standard patterns +- **Create new widgets** in design system if reused >3 features + +### Navigation in Widgets + +Widgets with navigation MUST use safe methods: + +**✅ CORRECT:** +```dart +// In UiAppBar back button: +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/krow_core.dart'; + +IconButton( + icon: Icon(UiIcons.chevronLeft), + onPressed: () => Modular.to.popSafe(), // ← Safe pop +) +``` + +**❌ FORBIDDEN:** +```dart +// ❌ Direct Navigator +IconButton( + icon: Icon(UiIcons.chevronLeft), + onPressed: () => Navigator.pop(context), +) + +// ❌ Unsafe Modular +IconButton( + icon: Icon(UiIcons.chevronLeft), + onPressed: () => Modular.to.pop(), // Can crash +) +``` + +### Composition Over Inheritance + +**✅ CORRECT:** +```dart +// Compose standard widgets +Container( + padding: EdgeInsets.all(UiConstants.spacingL), + decoration: BoxDecoration( + color: UiColors.card, + borderRadius: BorderRadius.circular(UiConstants.radiusM), + ), + child: Column( + children: [ + Text('Title', style: UiTypography.heading1sb), + SizedBox(height: UiConstants.spacingM), + Text('Body', style: UiTypography.body1r), + ], + ), +) +``` + +**❌ AVOID:** +```dart +// ❌ Deep custom widget hierarchies +class CustomCard extends StatelessWidget { + // Complex custom implementation +} +``` + +## 8. Theme Configuration + +### App Setup + +Apps initialize theme ONCE in root MaterialApp: + +**✅ CORRECT:** +```dart +// apps/mobile/apps/staff/lib/app_widget.dart +import 'package:design_system/design_system.dart'; + +class StaffApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MaterialApp.router( + theme: StaffTheme.light, // ← Design system theme + darkTheme: StaffTheme.dark, // ← Optional dark mode + themeMode: ThemeMode.system, + // ... + ); + } +} +``` + +**❌ FORBIDDEN:** +```dart +// ❌ Custom theme in app +MaterialApp.router( + theme: ThemeData( + primaryColor: Colors.blue, // ← NO! + ), +) + +// ❌ Theme override in feature +Theme( + data: ThemeData(...), + child: MyFeatureWidget(), +) +``` + +### Accessing Theme + +**Both methods valid:** +```dart +// Method 1: Direct design system import +import 'package:design_system/design_system.dart'; +Text('Hello', style: UiTypography.body1r) + +// Method 2: Via theme context +Text('Hello', style: Theme.of(context).textTheme.bodyMedium) +``` + +**Prefer Method 1** for explicit type safety. + +## 9. POC → Production Workflow + +### Step 1: Implement Structure (POC Matching) + +Implement UI layout exactly matching POC: +```dart +// Temporary: Match POC visually +Container( + color: Color(0xFF1A2234), // ← POC color + padding: EdgeInsets.all(16.0), // ← POC spacing + child: Text( + 'Title', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), // ← POC style + ), +) +``` + +**Purpose:** Ensure visual parity with POC before refactoring. + +### Step 2: Architecture Refactor + +Move to Clean Architecture: +- Extract business logic to use cases +- Move state management to BLoCs +- Implement repository pattern +- Use dependency injection + +### Step 3: Design System Integration + +Replace hardcoded values: +```dart +// Production: Design system tokens +Container( + color: UiColors.background, // ← Found closest match + padding: EdgeInsets.all(UiConstants.spacingL), // ← Used constant + child: Text( + 'Title', + style: UiTypography.heading1sb, // ← Matched typography + ), +) +``` + +**Color Matching:** +- POC `#1A2234` → `UiColors.background` +- POC `#3498DB` → `UiColors.primary` +- POC `#27AE60` → `UiColors.success` + +**Typography Matching:** +- POC `24px bold` → `UiTypography.heading1sb` +- POC `16px regular` → `UiTypography.body1r` +- POC `14px medium` → `UiTypography.caption1m` + +**Spacing Matching:** +- POC `16px` → `UiConstants.spacingL` +- POC `8px` → `UiConstants.spacingM` +- POC `4px` → `UiConstants.spacingS` + +## 10. Anti-Patterns & Common Mistakes + +### ❌ Magic Numbers +```dart +// BAD +EdgeInsets.all(12.0) +SizedBox(height: 24.0) +BorderRadius.circular(8.0) + +// GOOD +EdgeInsets.all(UiConstants.spacingM) +SizedBox(height: UiConstants.spacingL) +BorderRadius.circular(UiConstants.radiusM) +``` + +### ❌ Local Themes +```dart +// BAD +Theme( + data: ThemeData(primaryColor: Colors.blue), + child: MyWidget(), +) + +// GOOD +// Use global theme defined in app +``` + +### ❌ Hex Hunting +```dart +// BAD: Copy-paste from Figma +Container(color: Color(0xFF3498DB)) + +// GOOD: Find matching design system color +Container(color: UiColors.primary) +``` + +### ❌ Direct Icon Library +```dart +// BAD +import 'package:lucide_icons/lucide_icons.dart'; +Icon(LucideIcons.home) + +// GOOD +Icon(UiIcons.home) +``` + +### ❌ Custom Text Styles +```dart +// BAD +Text('Title', style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + fontFamily: 'Inter', +)) + +// GOOD +Text('Title', style: UiTypography.heading1sb) +``` + +## 11. Design System Review Checklist + +Before merging UI code: + +### ✅ Design System Compliance +- [ ] No hardcoded `Color(...)` or `0xFF...` hex values +- [ ] No custom `TextStyle(...)` definitions +- [ ] All spacing uses `UiConstants.spacing*` +- [ ] All radius uses `UiConstants.radius*` +- [ ] All elevation uses `UiConstants.elevation*` +- [ ] All icons from `UiIcons`, not direct library imports +- [ ] Theme consumed from design system, no local overrides +- [ ] Layout matches POC intent using design system primitives + +### ✅ Architecture Compliance +- [ ] No business logic in widgets +- [ ] State managed by BLoCs +- [ ] Navigation uses Modular safe extensions +- [ ] Localization used for all text (no hardcoded strings) +- [ ] No direct Data Connect queries in widgets + +### ✅ Code Quality +- [ ] Widget build methods concise (<50 lines) +- [ ] Complex widgets extracted to separate files +- [ ] Meaningful widget names +- [ ] Doc comments on reusable widgets + +## 12. When to Extend Design System + +### Add New Color +**When:** New brand color approved by design team + +**Process:** +1. Add to `ui_colors.dart`: +```dart +class UiColors { + static const myNewColor = Color(0xFF123456); +} +``` +2. Update theme if needed +3. Use in features + +### Add New Typography Style +**When:** New text style pattern emerges across multiple features + +**Process:** +1. Add to `ui_typography.dart`: +```dart +class UiTypography { + static const myNewStyle = TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + fontFamily: _fontFamily, + ); +} +``` +2. Use in features + +### Add Shared Widget +**When:** Widget reused in 3+ features + +**Process:** +1. Create in `lib/src/widgets/`: +```dart +// my_widget.dart +class MyWidget extends StatelessWidget { + // Implementation using design system tokens +} +``` +2. Export from `design_system.dart` +3. Use across features + +## Summary + +**Core Rules:** +1. **All colors from `UiColors`** - Zero hex codes in features +2. **All typography from `UiTypography`** - Zero custom TextStyle +3. **All spacing/radius/elevation from `UiConstants`** - Zero magic numbers +4. **All icons from `UiIcons`** - Zero direct library imports +5. **Theme defined once** in app entry point +6. **POC → Production** requires design system integration step + +**The Golden Rule:** Design system is immutable. Features adapt to the system, not the other way around. + +When implementing UI: +1. Import `package:design_system/design_system.dart` +2. Use design system tokens exclusively +3. Match POC intent with available tokens +4. Request new tokens only when truly necessary +5. Never create temporary hardcoded workarounds + +Visual consistency is non-negotiable. Every pixel must come from the design system. diff --git a/.agent/skills/krow-mobile-development-rules/SKILL.md b/.agent/skills/krow-mobile-development-rules/SKILL.md new file mode 100644 index 00000000..4f4adc0f --- /dev/null +++ b/.agent/skills/krow-mobile-development-rules/SKILL.md @@ -0,0 +1,646 @@ +--- +name: krow-mobile-development-rules +description: Enforce KROW mobile app development standards including file structure, naming conventions, logic placement boundaries, localization, Data Connect integration, and prototype migration rules. Use this skill whenever working on KROW Flutter mobile features, creating new packages, implementing BLoCs, integrating with backend, or migrating from prototypes. Critical for maintaining clean architecture and preventing architectural degradation. +--- + +# KROW Mobile Development Rules + +These rules are **NON-NEGOTIABLE** enforcement guidelines for the KROW mobile application. They prevent architectural degradation and ensure consistency across the codebase. + +## When to Use This Skill + +- Creating new mobile features or packages +- Implementing BLoCs, Use Cases, or Repositories +- Integrating with Firebase Data Connect backend +- Migrating code from prototypes +- Reviewing mobile code for compliance +- Setting up new feature modules +- Handling user sessions and authentication +- Implementing navigation flows + +## 1. File Creation & Package Structure + +### Feature-First Packaging + +**✅ DO:** +- Create new features as independent packages: + ``` + apps/mobile/packages/features/// + ├── lib/ + │ ├── src/ + │ │ ├── domain/ + │ │ │ ├── repositories/ + │ │ │ └── usecases/ + │ │ ├── data/ + │ │ │ └── repositories_impl/ + │ │ └── presentation/ + │ │ ├── blocs/ + │ │ ├── pages/ + │ │ └── widgets/ + │ └── .dart # Barrel file + └── pubspec.yaml + ``` + +**❌ DON'T:** +- Add features to `apps/mobile/packages/core` directly +- Create files in app directories (`apps/mobile/apps/client/` or `apps/mobile/apps/staff/`) +- Create cross-feature or cross-app dependencies (features must not import other features) + +### Path Conventions (Strict) + +Follow these exact paths: + +| Layer | Path Pattern | Example | +|-------|-------------|---------| +| **Entities** | `apps/mobile/packages/domain/lib/src/entities/.dart` | `user.dart`, `shift.dart` | +| **Repository Interface** | `.../features///lib/src/domain/repositories/_repository_interface.dart` | `auth_repository_interface.dart` | +| **Repository Impl** | `.../features///lib/src/data/repositories_impl/_repository_impl.dart` | `auth_repository_impl.dart` | +| **Use Cases** | `.../features///lib/src/application/_usecase.dart` | `login_usecase.dart` | +| **BLoCs** | `.../features///lib/src/presentation/blocs/_bloc.dart` | `auth_bloc.dart` | +| **Pages** | `.../features///lib/src/presentation/pages/_page.dart` | `login_page.dart` | +| **Widgets** | `.../features///lib/src/presentation/widgets/_widget.dart` | `password_field.dart` | + +### Barrel Files + +**✅ DO:** +```dart +// lib/auth_feature.dart +export 'src/presentation/pages/login_page.dart'; +export 'src/domain/repositories/auth_repository_interface.dart'; +// Only export PUBLIC API +``` + +**❌ DON'T:** +```dart +// Don't export internal implementation details +export 'src/data/repositories_impl/auth_repository_impl.dart'; +export 'src/presentation/blocs/auth_bloc.dart'; +``` + +## 2. Naming Conventions (Dart Standard) + +| Type | Convention | Example | File Name | +|------|-----------|---------|-----------| +| **Files** | `snake_case` | `user_profile_page.dart` | - | +| **Classes** | `PascalCase` | `UserProfilePage` | - | +| **Variables** | `camelCase` | `userProfile` | - | +| **Interfaces** | End with `Interface` | `AuthRepositoryInterface` | `auth_repository_interface.dart` | +| **Implementations** | End with `Impl` | `AuthRepositoryImpl` | `auth_repository_impl.dart` | +| **BLoCs** | End with `Bloc` or `Cubit` | `AuthBloc`, `ProfileCubit` | `auth_bloc.dart` | +| **Use Cases** | End with `UseCase` | `LoginUseCase` | `login_usecase.dart` | + +## 3. Logic Placement (Zero Tolerance Boundaries) + +### Business Rules → Use Cases ONLY + +**✅ CORRECT:** +```dart +// login_usecase.dart +class LoginUseCase extends UseCase { + @override + Future> call(LoginParams params) async { + // Business logic here: validation, transformation, orchestration + if (params.email.isEmpty) { + return Left(ValidationFailure('Email required')); + } + return await repository.login(params); + } +} +``` + +**❌ FORBIDDEN:** +```dart +// ❌ Business logic in BLoC +class AuthBloc extends Bloc { + on((event, emit) { + if (event.email.isEmpty) { // ← NO! This is business logic + emit(AuthError('Email required')); + } + }); +} + +// ❌ Business logic in Widget +class LoginPage extends StatelessWidget { + void _login() { + if (_emailController.text.isEmpty) { // ← NO! This is business logic + showSnackbar('Email required'); + } + } +} +``` + +### State Logic → BLoCs ONLY + +**✅ CORRECT:** +```dart +// auth_bloc.dart +class AuthBloc extends Bloc { + on((event, emit) async { + emit(AuthLoading()); + final result = await loginUseCase(LoginParams(email: event.email)); + result.fold( + (failure) => emit(AuthError(failure)), + (user) => emit(AuthAuthenticated(user)), + ); + }); +} + +// login_page.dart (StatelessWidget) +class LoginPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is AuthLoading) return LoadingIndicator(); + if (state is AuthError) return ErrorWidget(state.message); + return LoginForm(); + }, + ); + } +} +``` + +**❌ FORBIDDEN:** +```dart +// ❌ setState in Pages for complex state +class LoginPage extends StatefulWidget { + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + bool _isLoading = false; // ← NO! Use BLoC + String? _error; // ← NO! Use BLoC + + void _login() { + setState(() => _isLoading = true); // ← NO! Use BLoC + } +} +``` + +**RECOMMENDATION:** Pages should be `StatelessWidget` with state delegated to BLoCs. + +### Data Transformation → Repositories + +**✅ CORRECT:** +```dart +// profile_repository_impl.dart +class ProfileRepositoryImpl implements ProfileRepositoryInterface { + @override + Future getProfile(String id) async { + final response = await dataConnect.getStaffById(id: id).execute(); + // Data transformation happens here + return Staff( + id: response.data.staff.id, + name: response.data.staff.name, + // Map Data Connect model to Domain entity + ); + } +} +``` + +**❌ FORBIDDEN:** +```dart +// ❌ JSON parsing in UI +class ProfilePage extends StatelessWidget { + @override + Widget build(BuildContext context) { + final json = jsonDecode(response.body); // ← NO! + final name = json['name']; + } +} + +// ❌ JSON parsing in Domain Use Case +class GetProfileUseCase extends UseCase { + @override + Future> call(String id) async { + final response = await http.get('/staff/$id'); + final json = jsonDecode(response.body); // ← NO! + } +} +``` + +### Navigation → Flutter Modular + Safe Extensions + +**✅ CORRECT:** +```dart +// Use Safe Navigation Extensions +import 'package:krow_core/krow_core.dart'; + +// In widget/BLoC: +Modular.to.safePush('/profile'); +Modular.to.safeNavigate('/home'); +Modular.to.popSafe(); + +// Even better: Use Typed Navigators +Modular.to.toStaffHome(); // Defined in StaffNavigator +Modular.to.toShiftDetails(shiftId: '123'); +``` + +**❌ FORBIDDEN:** +```dart +// ❌ Direct Navigator.push +Navigator.push( + context, + MaterialPageRoute(builder: (_) => ProfilePage()), +); + +// ❌ Direct Modular navigation without safety +Modular.to.navigate('/profile'); // ← Can cause blank screens +Modular.to.pop(); // ← Can crash if stack is empty +``` + +**PATTERN:** All navigation MUST have fallback to Home page. Safe extensions automatically handle this. + +### Session Management → DataConnectService + SessionHandlerMixin + +**✅ CORRECT:** +```dart +// In main.dart: +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Initialize session listener (pick allowed roles for app) + DataConnectService.instance.initializeAuthListener( + allowedRoles: ['STAFF', 'BOTH'], // for staff app + ); + + runApp( + SessionListener( // Wraps entire app + child: ModularApp(module: AppModule(), child: AppWidget()), + ), + ); +} + +// In repository: +class ProfileRepositoryImpl implements ProfileRepositoryInterface { + final DataConnectService _service = DataConnectService.instance; + + @override + Future getProfile(String id) async { + // _service.run() handles: + // - Auth validation + // - Token refresh (if <5 min to expiry) + // - Error handling with 3 retries + return await _service.run(() async { + final response = await _service.connector + .getStaffById(id: id) + .execute(); + return _mapToStaff(response.data.staff); + }); + } +} +``` + +**PATTERN:** +- **SessionListener** widget wraps app and shows dialogs for session errors +- **SessionHandlerMixin** in `DataConnectService` provides automatic token refresh +- **3-attempt retry logic** with exponential backoff (1s → 2s → 4s) +- **Role validation** configurable per app + +## 4. Localization Integration (core_localization) + +All user-facing text MUST be localized. + +### String Management + +**✅ CORRECT:** +```dart +// In presentation layer: +import 'package:core_localization/core_localization.dart'; + +class LoginPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Text(context.strings.loginButton); // ← From localization + return ElevatedButton( + onPressed: _login, + child: Text(context.strings.submit), + ); + } +} +``` + +**❌ FORBIDDEN:** +```dart +// ❌ Hardcoded English strings +Text('Login') +Text('Submit') +ElevatedButton(child: Text('Click here')) +``` + +### BLoC Integration + +**✅ CORRECT:** +```dart +// BLoCs emit domain failures (not localized strings) +class AuthBloc extends Bloc { + on((event, emit) async { + final result = await loginUseCase(params); + result.fold( + (failure) => emit(AuthError(failure)), // ← Domain failure + (user) => emit(AuthAuthenticated(user)), + ); + }); +} + +// UI translates failures to user-friendly messages +class LoginPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is AuthError) { + final message = ErrorTranslator.translate( + state.failure, + context.strings, + ); + return ErrorWidget(message); // ← Localized + } + }, + ); + } +} +``` + +### App Setup + +Apps must import `LocalizationModule()`: +```dart +// app_module.dart +class AppModule extends Module { + @override + List get imports => [ + LocalizationModule(), // ← Required + DataConnectModule(), + ]; +} + +// main.dart +runApp( + BlocProvider( // ← Expose locale state + create: (_) => Modular.get(), + child: TranslationProvider( // ← Enable context.strings + child: MaterialApp.router(...), + ), + ), +); +``` + +## 5. Data Connect Integration + +All backend access goes through `DataConnectService`. + +### Repository Pattern + +**Step 1:** Define interface in feature domain: +```dart +// domain/repositories/profile_repository_interface.dart +abstract interface class ProfileRepositoryInterface { + Future getProfile(String id); + Future updateProfile(Staff profile); +} +``` + +**Step 2:** Implement using `DataConnectService.run()`: +```dart +// data/repositories_impl/profile_repository_impl.dart +class ProfileRepositoryImpl implements ProfileRepositoryInterface { + final DataConnectService _service = DataConnectService.instance; + + @override + Future getProfile(String id) async { + return await _service.run(() async { + final response = await _service.connector + .getStaffById(id: id) + .execute(); + return _mapToStaff(response.data.staff); + }); + } +} +``` + +**Benefits of `_service.run()`:** +- ✅ Automatic auth validation +- ✅ Token refresh if needed +- ✅ 3-attempt retry with exponential backoff +- ✅ Consistent error handling + +### Session Store Pattern + +After successful auth, populate session stores: +```dart +// For Staff App: +StaffSessionStore.instance.setSession( + StaffSession( + user: user, + staff: staff, + ownerId: ownerId, + ), +); + +// For Client App: +ClientSessionStore.instance.setSession( + ClientSession( + user: user, + business: business, + ), +); +``` + +**Lazy Loading:** If session is null, fetch via `getStaffById()` or `getBusinessById()` and update store. + +## 6. Prototype Migration Rules + +When migrating from `prototypes/`: + +### ✅ MAY Copy +- Icons, images, assets (but match to design system) +- `build` methods for UI layout structure +- Screen flow and navigation patterns + +### ❌ MUST REJECT & REFACTOR +- `GetX`, `Provider`, or `MVC` patterns +- Any state management not using BLoC +- Direct HTTP calls (must use Data Connect) +- Hardcoded colors/typography (must use design system) +- Global state variables +- Navigation without Modular + +### Colors & Typography Migration +**When matching POC to production:** +1. Find closest color in `UiColors` (don't add new colors without approval) +2. Find closest text style in `UiTypography` +3. Use design system constants, NOT POC hardcoded values + +**DO NOT change the design system itself.** Colors and typography are FINAL. Match your feature to the system, not the other way around. + +## 7. Handling Ambiguity + +If requirements are unclear: + +1. **STOP** - Don't guess domain fields or workflows +2. **ANALYZE** - Refer to: + - Architecture: `apps/mobile/docs/01-architecture-principles.md` + - Design System: `apps/mobile/docs/02-design-system-usage.md` + - Existing features for patterns +3. **DOCUMENT** - Add `// ASSUMPTION: ` if you must proceed +4. **ASK** - Prefer asking user for clarification on business rules + +## 8. Dependencies + +### DO NOT +- Add 3rd party packages without checking `apps/mobile/packages/core` first +- Add `firebase_auth` or `firebase_data_connect` to Feature packages (they belong in `data_connect` only) +- Use `addSingleton` for BLoCs (always use `add` method in Modular) + +### DO +- Use `DataConnectService.instance` for backend operations +- Use Flutter Modular for dependency injection +- Register BLoCs with `i.addSingleton(() => CubitType(...))` +- Register Use Cases as factories or singletons as needed + +## 9. Error Handling Pattern + +### Domain Failures +```dart +// domain/failures/auth_failure.dart +abstract class AuthFailure extends Failure { + const AuthFailure(String message) : super(message); +} + +class InvalidCredentialsFailure extends AuthFailure { + const InvalidCredentialsFailure() : super('Invalid credentials'); +} +``` + +### Repository Error Mapping +```dart +// Map Data Connect exceptions to Domain failures +try { + final response = await dataConnect.query(); + return Right(response); +} on DataConnectException catch (e) { + if (e.message.contains('unauthorized')) { + return Left(InvalidCredentialsFailure()); + } + return Left(ServerFailure(e.message)); +} +``` + +### UI Feedback +```dart +// BLoC emits error state +emit(AuthError(failure)); + +// UI shows user-friendly message +if (state is AuthError) { + final message = ErrorTranslator.translate(state.failure, context.strings); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); +} +``` + +### Session Errors +`SessionListener` automatically shows dialogs for: +- Session expiration +- Token refresh failures +- Network errors during auth + +## 10. Testing Requirements + +### Unit Tests +```dart +// Test use cases with real repository implementations +test('login with valid credentials returns user', () async { + final useCase = LoginUseCase(repository: mockRepository); + final result = await useCase(LoginParams(email: 'test@test.com')); + expect(result.isRight(), true); +}); +``` + +### Widget Tests +```dart +// Test UI widgets and BLoC interactions +testWidgets('shows loading indicator when logging in', (tester) async { + await tester.pumpWidget( + BlocProvider( + create: (_) => authBloc, + child: LoginPage(), + ), + ); + + authBloc.add(LoginRequested(email: 'test@test.com')); + await tester.pump(); + + expect(find.byType(LoadingIndicator), findsOneWidget); +}); +``` + +### Integration Tests +- Test full feature flows end-to-end with Data Connect +- Use dependency injection to swap implementations if needed + +## 11. Clean Code Principles + +### Documentation +- ✅ Add human readable doc comments for `dartdoc` for all classes and methods. +```dart +/// Authenticates user with email and password. +/// +/// Returns [User] on success or [AuthFailure] on failure. +/// Throws [NetworkException] if connection fails. +class LoginUseCase extends UseCase { + // ... +} +``` + +### Single Responsibility +- Keep methods focused on one task +- Extract complex logic to separate methods +- Keep widget build methods concise +- Extract complex widgets to separate files + +### Meaningful Names +```dart +// ✅ GOOD +final isProfileComplete = await checkProfileCompletion(); +final userShifts = await fetchUserShifts(); + +// ❌ BAD +final flag = await check(); +final data = await fetch(); +``` + +## Enforcement Checklist + +Before merging any mobile feature code: + +### Architecture Compliance +- [ ] Feature follows package structure (domain/data/presentation) +- [ ] No business logic in BLoCs or Widgets +- [ ] All state management via BLoCs +- [ ] All backend access via repositories +- [ ] Session accessed via SessionStore, not global state +- [ ] Navigation uses Flutter Modular safe extensions +- [ ] No feature-to-feature imports + +### Code Quality +- [ ] No hardcoded strings (use localization) +- [ ] No hardcoded colors/typography (use design system) +- [ ] All spacing uses UiConstants +- [ ] Doc comments on public APIs +- [ ] Meaningful variable names +- [ ] Zero analyzer warnings + +### Integration +- [ ] Data Connect queries via `_service.run()` +- [ ] Error handling with domain failures +- [ ] Proper dependency injection in modules + +## Summary + +The key principle: **Clean Architecture with zero tolerance for violations.** Business logic in Use Cases, state in BLoCs, data access in Repositories, UI in Widgets. Features are isolated, backend is centralized, localization is mandatory, and design system is immutable. + +When in doubt, refer to existing features following these patterns or ask for clarification. It's better to ask than to introduce architectural debt. diff --git a/.agent/skills/krow-mobile-release/SKILL.md b/.agent/skills/krow-mobile-release/SKILL.md new file mode 100644 index 00000000..78e2b38f --- /dev/null +++ b/.agent/skills/krow-mobile-release/SKILL.md @@ -0,0 +1,778 @@ +--- +name: krow-mobile-release +description: KROW mobile app release process including versioning strategy, CHANGELOG management, GitHub Actions workflows, APK signing, Git tagging, and hotfix procedures. Use this when preparing mobile releases, updating CHANGELOGs, triggering release workflows, creating hotfix branches, troubleshooting release issues, or documenting release features. Covers both staff (worker) and client mobile products across dev/stage/prod environments. +--- + +# KROW Mobile Release Process + +This skill defines the comprehensive release process for KROW mobile applications (staff and client). It covers versioning, changelog management, GitHub Actions automation, and hotfix procedures. + +## When to Use This Skill + +- Preparing for a mobile app release +- Updating CHANGELOG files with new features +- Triggering GitHub Actions release workflows +- Creating hotfix branches for production issues +- Understanding version numbering strategy +- Setting up APK signing secrets +- Troubleshooting release workflow failures +- Documenting release notes +- Managing release cadence (dev → stage → prod) + +## Quick Reference + +### Release Workflows +- **Product Release:** [GitHub Actions - Product Release](https://github.com/Oloodi/krow-workforce/actions/workflows/product-release.yml) +- **Hotfix Creation:** [GitHub Actions - Product Hotfix](https://github.com/Oloodi/krow-workforce/actions/workflows/hotfix-branch-creation.yml) + +### Key Files +- **Staff CHANGELOG:** `apps/mobile/apps/staff/CHANGELOG.md` +- **Client CHANGELOG:** `apps/mobile/apps/client/CHANGELOG.md` +- **Staff Version:** `apps/mobile/apps/staff/pubspec.yaml` +- **Client Version:** `apps/mobile/apps/client/pubspec.yaml` + +### Comprehensive Documentation +For complete details, see: [`docs/RELEASE/mobile-releases.md`](docs/RELEASE/mobile-releases.md) (900+ lines) + +## 1. Versioning Strategy + +### Format + +``` +v{major}.{minor}.{patch}-{milestone} +``` + +**Examples:** +- `v0.0.1-m4` - Milestone 4 release +- `v0.1.0-m5` - Minor version bump for Milestone 5 +- `v1.0.0` - First production release (no milestone suffix) + +### Semantic Versioning Rules + +**Major (X.0.0):** +- Breaking changes +- Complete architecture overhaul +- Incompatible API changes + +**Minor (0.X.0):** +- New features +- Backwards-compatible additions +- Milestone completions + +**Patch (0.0.X):** +- Bug fixes +- Security patches +- Performance improvements + +**Milestone Suffix:** +- `-m1`, `-m2`, `-m3`, `-m4`, etc. +- Indicates pre-production milestone phase +- Removed for production releases + +### Version Location + +Versions are defined in `pubspec.yaml`: + +**Staff App:** +```yaml +# apps/mobile/apps/staff/pubspec.yaml +name: krow_staff_app +version: 0.0.1-m4+1 # version+build_number +``` + +**Client App:** +```yaml +# apps/mobile/apps/client/pubspec.yaml +name: krow_client_app +version: 0.0.1-m4+1 +``` + +**Format:** `version+build` +- `version`: Semantic version with milestone (e.g., `0.0.1-m4`) +- `build`: Build number (increments with each build, e.g., `+1`, `+2`) + +## 2. CHANGELOG Management + +### Format + +Each app maintains a separate CHANGELOG following [Keep a Changelog](https://keepachangelog.com/) format. + +**Structure:** +```markdown +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +### Added +- New feature descriptions + +### Changed +- Modified feature descriptions + +### Fixed +- Bug fix descriptions + +### Removed +- Removed feature descriptions + +## [0.0.1-m4] - Milestone 4 - 2026-03-05 + +### Added +- Profile management with 13 subsections +- Documents & certificates management +- Benefits overview section +- Camera/gallery support for attire verification + +### Changed +- Enhanced session management with auto token refresh + +### Fixed +- Navigation fallback to home on invalid routes +``` + +### Section Guidelines + +**[Unreleased]** +- Work in progress +- Features merged to dev but not released +- Updated continuously during development + +**[Version] - Milestone X - Date** +- Released version +- Format: `[X.Y.Z-mN] - Milestone N - YYYY-MM-DD` +- Organized by change type (Added/Changed/Fixed/Removed) + +### Change Type Definitions + +**Added:** +- New features +- New UI screens +- New API integrations +- New user-facing capabilities + +**Changed:** +- Modifications to existing features +- UI/UX improvements +- Performance enhancements +- Refactored code (if user-facing impact) + +**Fixed:** +- Bug fixes +- Error handling improvements +- Crash fixes +- UI/UX issues resolved + +**Removed:** +- Deprecated features +- Removed screens or capabilities +- Discontinued integrations + +### Writing Guidelines + +**✅ GOOD:** +```markdown +### Added +- Profile management with 13 subsections organized into onboarding, compliance, finances, and support categories +- Documents & certificates management with upload, status tracking, and expiry dates +- Camera and gallery support for attire verification with photo capture +- Benefits overview section displaying perks and company information +``` + +**❌ BAD:** +```markdown +### Added +- New stuff +- Fixed things +- Updated code +``` + +**Key Principles:** +- Be specific and descriptive +- Focus on user-facing changes +- Mention UI screens, features, or capabilities +- Avoid technical jargon users won't understand +- Group related changes together + +### Updating CHANGELOG Workflow + +**Step 1:** During development, add to `[Unreleased]`: +```markdown +## [Unreleased] + +### Added +- New shift calendar view with month/week toggle +- Shift acceptance confirmation dialog + +### Fixed +- Navigation crash when popping empty stack +``` + +**Step 2:** Before release, move to version section: +```markdown +## [0.1.0-m5] - Milestone 5 - 2026-03-15 + +### Added +- New shift calendar view with month/week toggle +- Shift acceptance confirmation dialog + +### Fixed +- Navigation crash when popping empty stack + +## [Unreleased] + +``` + +**Step 3:** Update version in `pubspec.yaml`: +```yaml +version: 0.1.0-m5+1 +``` + +## 3. Git Tagging Strategy + +### Tag Format + +``` +krow-withus--mobile/-vX.Y.Z +``` + +**Components:** +- ``: `worker` (staff) or `client` +- ``: `dev`, `stage`, or `prod` +- `vX.Y.Z`: Semantic version (from pubspec.yaml) + +**Examples:** +``` +krow-withus-worker-mobile/dev-v0.0.1-m4 +krow-withus-worker-mobile/stage-v0.0.1-m4 +krow-withus-worker-mobile/prod-v0.0.1-m4 +krow-withus-client-mobile/dev-v0.0.1-m4 +``` + +### Tag Creation + +Tags are created automatically by GitHub Actions workflows. Manual tagging: + +```bash +# Staff app - dev environment +git tag krow-withus-worker-mobile/dev-v0.0.1-m4 +git push origin krow-withus-worker-mobile/dev-v0.0.1-m4 + +# Client app - prod environment +git tag krow-withus-client-mobile/prod-v1.0.0 +git push origin krow-withus-client-mobile/prod-v1.0.0 +``` + +### Tag Listing + +```bash +# List all mobile tags +git tag -l "krow-withus-*-mobile/*" + +# List staff app tags +git tag -l "krow-withus-worker-mobile/*" + +# List production tags +git tag -l "krow-withus-*-mobile/prod-*" +``` + +## 4. GitHub Actions Workflows + +### 4.1 Product Release Workflow + +**File:** `.github/workflows/product-release.yml` + +**Purpose:** Automated production releases with APK signing + +**Trigger:** Manual dispatch via GitHub UI + +**Inputs:** +- `app`: Select `worker` (staff) or `client` +- `environment`: Select `dev`, `stage`, or `prod` + +**Process:** +1. ✅ Extracts version from `pubspec.yaml` automatically +2. ✅ Builds signed APKs for selected app +3. ✅ Creates GitHub release with CHANGELOG notes +4. ✅ Tags release (e.g., `krow-withus-worker-mobile/dev-v0.0.1-m4`) +5. ✅ Uploads APKs as release assets +6. ✅ Generates step summary with emojis + +**Key Features:** +- **No manual version input** - reads from pubspec.yaml +- **APK signing** - uses GitHub Secrets for keystore +- **CHANGELOG extraction** - pulls release notes automatically +- **Visual feedback** - emojis in all steps + +**Usage:** +``` +1. Go to: GitHub Actions → "📦 Product Release" +2. Click "Run workflow" +3. Select app (worker/client) +4. Select environment (dev/stage/prod) +5. Click "Run workflow" +6. Wait for completion (~5-10 minutes) +``` + +**Release Naming:** +``` +Krow With Us - Worker Product - DEV - v0.0.1-m4 +Krow With Us - Client Product - PROD - v1.0.0 +``` + +### 4.2 Product Hotfix Workflow + +**File:** `.github/workflows/hotfix-branch-creation.yml` + +**Purpose:** Emergency production fix automation + +**Trigger:** Manual dispatch with version input + +**Inputs:** +- `current_version`: Current production version (e.g., `0.0.1-m4`) +- `issue_description`: Brief description of the hotfix + +**Process:** +1. ✅ Creates `hotfix/` branch from latest production tag +2. ✅ Auto-increments PATCH version (e.g., `0.0.1-m4` → `0.0.2-m4`) +3. ✅ Updates `pubspec.yaml` with new version +4. ✅ Updates `CHANGELOG.md` with hotfix section +5. ✅ Creates PR back to main branch +6. ✅ Includes hotfix instructions in PR description + +**Usage:** +``` +1. Go to: GitHub Actions → "🚨 Product Hotfix - Create Branch" +2. Click "Run workflow" +3. Enter current production version (e.g., 0.0.1-m4) +4. Enter issue description (e.g., "critical crash on login") +5. Click "Run workflow" +6. Workflow creates branch and PR +7. Fix bug on hotfix branch +8. Merge PR to main +9. Use Product Release workflow to deploy +``` + +**Hotfix Branch Naming:** +``` +hotfix/0.0.2-m4-critical-crash-on-login +``` + +### 4.3 Helper Scripts + +**Location:** `.github/scripts/` + +**Available Scripts:** +1. **extract-version.sh** - Extract version from pubspec.yaml +2. **generate-tag-name.sh** - Generate standardized tag names +3. **extract-release-notes.sh** - Extract CHANGELOG sections +4. **create-release-summary.sh** - Generate GitHub Step Summary with emojis + +**Script Permissions:** +```bash +chmod +x .github/scripts/*.sh +``` + +**Usage Example:** +```bash +# Extract version from staff app +.github/scripts/extract-version.sh apps/mobile/apps/staff/pubspec.yaml + +# Generate tag name +.github/scripts/generate-tag-name.sh worker dev 0.0.1-m4 + +# Extract release notes for version +.github/scripts/extract-release-notes.sh apps/mobile/apps/staff/CHANGELOG.md 0.0.1-m4 +``` + +## 5. APK Signing Setup + +### Required GitHub Secrets (24 Total) + +**Per App (12 secrets each):** + +**Staff (Worker) App:** +``` +STAFF_UPLOAD_KEYSTORE_BASE64 # Base64-encoded keystore file +STAFF_UPLOAD_STORE_PASSWORD # Keystore password +STAFF_UPLOAD_KEY_ALIAS # Key alias +STAFF_UPLOAD_KEY_PASSWORD # Key password +STAFF_KEYSTORE_PROPERTIES_BASE64 # Base64-encoded key.properties file +``` + +**Client App:** +``` +CLIENT_UPLOAD_KEYSTORE_BASE64 +CLIENT_UPLOAD_STORE_PASSWORD +CLIENT_UPLOAD_KEY_ALIAS +CLIENT_UPLOAD_KEY_PASSWORD +CLIENT_KEYSTORE_PROPERTIES_BASE64 +``` + +### Generating Secrets + +**Step 1: Create Keystore** + +```bash +# For staff app +keytool -genkey -v \ + -keystore staff-upload-keystore.jks \ + -keyalg RSA \ + -keysize 2048 \ + -validity 10000 \ + -alias staff-upload + +# For client app +keytool -genkey -v \ + -keystore client-upload-keystore.jks \ + -keyalg RSA \ + -keysize 2048 \ + -validity 10000 \ + -alias client-upload +``` + +**Step 2: Base64 Encode** + +```bash +# Encode keystore +base64 -i staff-upload-keystore.jks | tr -d '\n' > staff-keystore.txt + +# Encode key.properties +base64 -i key.properties | tr -d '\n' > key-props.txt +``` + +**Step 3: Add to GitHub Secrets** + +``` +Repository → Settings → Secrets and variables → Actions → New repository secret +``` + +Add each secret: +- Name: `STAFF_UPLOAD_KEYSTORE_BASE64` +- Value: Contents of `staff-keystore.txt` + +Repeat for all 24 secrets. + +### key.properties Format + +```properties +storePassword=your_store_password +keyPassword=your_key_password +keyAlias=staff-upload +storeFile=../staff-upload-keystore.jks +``` + +## 6. Release Process (Step-by-Step) + +### Standard Release (Dev/Stage/Prod) + +**Step 1: Prepare CHANGELOG** + +Update `CHANGELOG.md` with all changes since last release: +```markdown +## [0.1.0-m5] - Milestone 5 - 2026-03-15 + +### Added +- Shift calendar with month/week views +- Enhanced navigation with typed routes +- Profile completion wizard + +### Fixed +- Session token refresh timing +- Navigation fallback logic +``` + +**Step 2: Update Version** + +Edit `pubspec.yaml`: +```yaml +version: 0.1.0-m5+1 # Changed from 0.0.1-m4+1 +``` + +**Step 3: Commit and Push** + +```bash +git add apps/mobile/apps/staff/CHANGELOG.md +git add apps/mobile/apps/staff/pubspec.yaml +git commit -m "chore(staff): prepare v0.1.0-m5 release" +git push origin dev +``` + +**Step 4: Trigger Workflow** + +1. Go to GitHub Actions → "📦 Product Release" +2. Click "Run workflow" +3. Select branch: `dev` +4. Select app: `worker` (or `client`) +5. Select environment: `dev` (or `stage`, `prod`) +6. Click "Run workflow" + +**Step 5: Monitor Progress** + +Watch workflow execution: +- ⏳ Version extraction +- ⏳ APK building +- ⏳ APK signing +- ⏳ GitHub Release creation +- ⏳ Tag creation +- ⏳ Asset upload + +**Step 6: Verify Release** + +1. Check GitHub Releases page +2. Download APK to verify +3. Install on test device +4. Verify version in app + +### Hotfix Release + +**Step 1: Identify Production Issue** + +- Critical bug in production +- User-reported crash +- Security vulnerability + +**Step 2: Trigger Hotfix Workflow** + +1. Go to GitHub Actions → "🚨 Product Hotfix - Create Branch" +2. Click "Run workflow" +3. Enter current version: `0.0.1-m4` +4. Enter description: `Critical crash on login screen` +5. Click "Run workflow" + +**Step 3: Review Created Branch** + +Workflow creates: +- Branch: `hotfix/0.0.2-m4-critical-crash-on-login` +- PR to `main` branch +- Updated `pubspec.yaml`: `0.0.2-m4+1` +- Updated `CHANGELOG.md` with hotfix section + +**Step 4: Fix Bug** + +```bash +git checkout hotfix/0.0.2-m4-critical-crash-on-login + +# Make fixes +# ... code changes ... + +git add . +git commit -m "fix(auth): resolve crash on login screen" +git push origin hotfix/0.0.2-m4-critical-crash-on-login +``` + +**Step 5: Merge PR** + +1. Review PR on GitHub +2. Approve and merge to `main` +3. Delete hotfix branch + +**Step 6: Release to Production** + +1. Use Product Release workflow +2. Select `main` branch +3. Select `prod` environment +4. Deploy hotfix + +## 7. Release Cadence + +### Development (dev) + +- **Frequency:** Multiple times per day +- **Purpose:** Testing features in dev environment +- **Branch:** `dev` +- **Audience:** Internal development team +- **Approval:** Not required + +### Staging (stage) + +- **Frequency:** 1-2 times per week +- **Purpose:** QA testing, stakeholder demos +- **Branch:** `main` +- **Audience:** QA team, stakeholders +- **Approval:** Tech lead approval + +### Production (prod) + +- **Frequency:** Every 2-3 weeks (milestone completion) +- **Purpose:** End-user releases +- **Branch:** `main` +- **Audience:** All users +- **Approval:** Product owner + tech lead approval + +### Milestone Releases + +- **Frequency:** Every 2-4 weeks +- **Version Bump:** Minor version (e.g., `0.1.0-m5` → `0.2.0-m6`) +- **Process:** + 1. Complete all milestone features + 2. Update CHANGELOG with comprehensive release notes + 3. Deploy to stage for final QA + 4. After approval, deploy to prod + 5. Create GitHub release with milestone summary + +## 8. Troubleshooting + +### Workflow Fails: Version Extraction + +**Error:** "Could not extract version from pubspec.yaml" + +**Solutions:** +1. Verify `pubspec.yaml` exists at expected path +2. Check version format: `version: X.Y.Z-mN+B` +3. Ensure no extra spaces or tabs +4. Verify file is committed and pushed + +### Workflow Fails: APK Signing + +**Error:** "Keystore password incorrect" + +**Solutions:** +1. Verify GitHub Secrets are set correctly +2. Re-generate and re-encode keystore +3. Check key.properties format +4. Ensure passwords don't contain special characters that need escaping + +### Workflow Fails: CHANGELOG Extraction + +**Error:** "Could not find version in CHANGELOG" + +**Solutions:** +1. Verify CHANGELOG format matches: `## [X.Y.Z-mN] - Milestone N - YYYY-MM-DD` +2. Check square brackets are present +3. Ensure version matches pubspec.yaml +4. Add version section if missing + +### Tag Already Exists + +**Error:** "tag already exists" + +**Solutions:** +1. Delete existing tag locally and remotely: +```bash +git tag -d krow-withus-worker-mobile/dev-v0.0.1-m4 +git push origin :refs/tags/krow-withus-worker-mobile/dev-v0.0.1-m4 +``` +2. Re-run workflow + +### Build Fails: Flutter Errors + +**Error:** "flutter build failed" + +**Solutions:** +1. Test build locally first: +```bash +cd apps/mobile/apps/staff +flutter build apk --release +``` +2. Fix any analyzer errors +3. Ensure all dependencies are compatible +4. Clear build cache: +```bash +flutter clean +flutter pub get +``` + +## 9. Local Testing + +Before triggering workflows, test builds locally: + +### Building APKs Locally + +**Staff App:** +```bash +cd apps/mobile/apps/staff +flutter clean +flutter pub get +flutter build apk --release +``` + +**Client App:** +```bash +cd apps/mobile/apps/client +flutter clean +flutter pub get +flutter build apk --release +``` + +### Testing Release Notes + +Extract CHANGELOG section: +```bash +.github/scripts/extract-release-notes.sh \ + apps/mobile/apps/staff/CHANGELOG.md \ + 0.0.1-m4 +``` + +### Verifying Version + +Extract version from pubspec: +```bash +.github/scripts/extract-version.sh \ + apps/mobile/apps/staff/pubspec.yaml +``` + +## 10. Best Practices + +### CHANGELOG +- ✅ Update continuously during development +- ✅ Be specific and user-focused +- ✅ Group related changes +- ✅ Include UI/UX changes +- ❌ Don't include technical debt or refactoring (unless user-facing) +- ❌ Don't use vague descriptions + +### Versioning +- ✅ Use semantic versioning strictly +- ✅ Increment patch for bug fixes +- ✅ Increment minor for new features +- ✅ Keep milestone suffix until production +- ❌ Don't skip versions +- ❌ Don't use arbitrary version numbers + +### Git Tags +- ✅ Follow standard format +- ✅ Let workflow create tags automatically +- ✅ Keep tags synced with releases +- ❌ Don't create tags manually unless necessary +- ❌ Don't reuse deleted tags + +### Workflows +- ✅ Test builds locally first +- ✅ Monitor workflow execution +- ✅ Verify release assets +- ✅ Test APK on device before announcing +- ❌ Don't trigger multiple workflows simultaneously +- ❌ Don't bypass approval process + +## Summary + +**Release Process Overview:** +1. Update CHANGELOG with changes +2. Update version in pubspec.yaml +3. Commit and push to appropriate branch +4. Trigger Product Release workflow +5. Monitor execution and verify release +6. Test APK on device +7. Announce to team/users + +**Key Files:** +- `apps/mobile/apps/staff/CHANGELOG.md` +- `apps/mobile/apps/client/CHANGELOG.md` +- `apps/mobile/apps/staff/pubspec.yaml` +- `apps/mobile/apps/client/pubspec.yaml` + +**Key Workflows:** +- Product Release (standard releases) +- Product Hotfix (emergency fixes) + +**For Complete Details:** +See [`docs/RELEASE/mobile-releases.md`](docs/RELEASE/mobile-releases.md) - 900+ line comprehensive guide with: +- Detailed APK signing setup +- Complete troubleshooting guide +- All helper scripts documentation +- Release checklist +- Security best practices + +When in doubt, refer to the comprehensive documentation or ask for clarification before releasing to production. diff --git a/.agent/skills/krow-paper-design/SKILL.md b/.agent/skills/krow-paper-design/SKILL.md new file mode 100644 index 00000000..df9b2994 --- /dev/null +++ b/.agent/skills/krow-paper-design/SKILL.md @@ -0,0 +1,413 @@ +--- +name: krow-paper-design +description: KROW Paper design file conventions covering design tokens, component patterns, screen structure, and naming rules. Use this when creating or updating screens in the Paper design tool, auditing designs for token compliance, building new flows, or restructuring existing frames. Ensures visual consistency across all Paper design files for the KROW staff and client apps. +--- + +# KROW Paper Design Conventions + +This skill defines the design token system, component patterns, screen structure conventions, and workflow rules established for the KROW Design Revamp Paper file. All design work in Paper must follow these conventions. + +## When to Use This Skill + +- Creating new screens or flows in Paper +- Updating existing frames to match the design system +- Auditing designs for token compliance +- Adding components (buttons, chips, inputs, badges, cards) +- Structuring shift detail pages, onboarding flows, or list screens +- Setting up navigation patterns (back buttons, bottom nav, CTAs) +- Reviewing Paper designs before handoff to development + +## 1. Design Tokens + +### Color Palette + +| Token | Hex | Usage | +|-------|-----|-------| +| Primary | `#0A39DF` | CTAs, active states, links, selected chips, nav active icons, pay rates | +| Foreground | `#121826` | Headings, primary text, dark UI elements | +| Text Secondary | `#6A7382` | Labels, captions, inactive nav, section headers, placeholder text, back chevrons | +| Secondary BG | `#F1F3F5` | Subtle backgrounds, dividers, map placeholders | +| Border | `#D1D5DB` | Card borders, unselected chip borders, outline button borders | +| Input Border | `#E2E8F0` | Text input borders (lighter than general border) | +| Destructive | `#F04444` | Error states, destructive actions (e.g., Request Swap) | +| Background | `#FAFBFC` | Page/artboard background | +| Card BG | `#FFFFFF` | Card surfaces, input backgrounds | +| Success | `#059669` | Active status dot, checkmark icons, requirement met | +| Warning Amber | `#D97706` | Urgent/Pending badge text | + +### Semantic Badge Colors + +| Badge | Background | Text Color | +|-------|-----------|------------| +| Active | `#ECFDF5` | `#059669` | +| Confirmed | `#EBF0FF` | `#0A39DF` | +| Pending | `#FEF9EE` | `#D97706` | +| Urgent | `#FEF9EE` | `#D97706` | +| One-Time | `#ECFDF5` | `#059669` | +| Recurring | `#EBF0FF` | `#0A39DF` (use `#EFF6FF` bg on detail pages) | + +### Typography + +| Style | Font | Size | Weight | Line Height | Usage | +|-------|------|------|--------|-------------|-------| +| Display | Inter Tight | 28px | 700 | 34px | Page titles (Find Shifts, My Shifts) | +| H1 | Inter Tight | 24px | 700 | 30px | Detail page titles (venue names) | +| H2 | Inter Tight | 20px | 700 | 26px | Section headings | +| H3 | Inter Tight | 18px | 700 | 22px | Card titles, schedule values | +| Body Large | Manrope | 16px | 600 | 20px | Button text, CTA labels | +| Body Default | Manrope | 14px | 400-500 | 18px | Body text, descriptions | +| Body Small | Manrope | 13px | 400-500 | 16px | Card metadata, time/pay info | +| Caption | Manrope | 12px | 500-600 | 16px | Small chip text, tab labels | +| Section Label | Manrope | 11px | 700 | 14px | Uppercase section headers (letter-spacing: 0.06em) | +| Badge Text | Manrope | 11px | 600-700 | 14px | Status badge labels (letter-spacing: 0.04em) | +| Nav Label | Manrope | 10px | 600 | 12px | Bottom nav labels | + +### Spacing + +| Token | Value | Usage | +|-------|-------|-------| +| Page padding | 24px | Horizontal padding from screen edge | +| Section gap | 16-24px | Between major content sections | +| Group gap | 8-12px | Within a section (e.g., label to input) | +| Element gap | 4px | Tight spacing (e.g., subtitle under title) | +| Bottom safe area | 40px | Padding below last element / CTA | + +### Border Radii + +| Token | Value | Usage | +|-------|-------|-------| +| sm | 8px | Small chips, badges, status pills, map placeholder | +| md | 12px | Cards, inputs, location cards, contact cards, search fields | +| lg | 14px | Buttons, CTA containers, shift cards (Find Shifts) | +| xl | 24px | Not commonly used | +| pill | 999px | Progress bar segments only | + +## 2. Component Patterns + +### Buttons + +**Primary CTA:** +- Background: `#0A39DF`, radius: 14px, height: 52px +- Text: Manrope 16px/600, color: `#FFFFFF` +- Padding: 16px vertical, 16px horizontal + +**Secondary/Outline Button:** +- Background: `#FFFFFF`, border: 1.5px `#D1D5DB`, radius: 14px, height: 52px +- Text: Manrope 16px/600, color: `#121826` + +**Destructive Outline Button:** +- Background: `#FFFFFF`, border: 1.5px `#F04444`, radius: 14px +- Text: Manrope 14px/600, color: `#F04444` + +**Back Icon Button (Bottom CTA):** +- 52x52px square, border: 1.5px `#D1D5DB`, radius: 14px, background: `#FFFFFF` +- Contains chevron-left SVG (20x20, viewBox 0 0 24 24, stroke `#121826`, strokeWidth 2) +- Path: `M15 18L9 12L15 6` + +### Chips + +**Default (Large) - for role/skill selection:** +- Selected: bg `#EFF6FF`, border 1.5px `#0A39DF`, radius 10px, padding 12px/16px + - Checkmark icon (14x14, stroke `#0A39DF`), text Manrope 14px/600 `#0A39DF` +- Unselected: bg `#FFFFFF`, border 1.5px `#6A7382`, radius 10px, padding 12px/16px + - Text Manrope 14px/500 `#6A7382` + +**Small - for tabs, filters:** +- Selected: bg `#EFF6FF`, border 1.5px `#0A39DF`, radius 8px, padding 6px/12px + - Checkmark icon (12x12), text Manrope 12px/600 `#0A39DF` +- Unselected: bg `#FFFFFF`, border 1.5px `#D1D5DB`, radius 8px, padding 6px/12px + - Text Manrope 12px/500 `#6A7382` +- Active (filled): bg `#0A39DF`, radius 8px, padding 6px/12px + - Text Manrope 12px/600 `#FFFFFF` +- Dark (filters button): bg `#121826`, radius 8px, padding 6px/12px + - Text Manrope 12px/600 `#FFFFFF`, with leading icon + +**Status Badges:** +- Radius: 8px, padding: 4px/8px +- Text: Manrope 11px/600-700, uppercase, letter-spacing 0.04em +- Colors follow semantic badge table above + +### Text Inputs + +- Border: 1.5px `#E2E8F0`, radius: 12px, padding: 12px/14px +- Background: `#FFFFFF` +- Placeholder: Manrope 14px/400, color `#6A7382` +- Filled: Manrope 14px/500, color `#121826` +- Label above: Manrope 14px/500, color `#121826` +- Focused: border color `#0A39DF`, border-width 2px +- Error: border color `#F04444`, helper text `#F04444` + +### Cards (Shift List Items) + +- Background: `#FFFFFF`, border: 1px `#D1D5DB`, radius: 12-14px +- Padding: 16px +- Content: venue name (Manrope 15px/600 `#121826`), subtitle (Manrope 13px/400 `#6A7382`) +- Metadata row: icon (14px, `#6A7382`) + text (Manrope 13px/500 `#6A7382`) +- Pay rate: Inter Tight 18px/700 `#0A39DF` + +### Schedule/Pay Info Cards + +- Two-column layout with 12px gap +- Background: `#FFFFFF`, border: 1px `#D1D5DB`, radius: 12px, padding: 16px +- Label: Manrope 11px/500-700 uppercase `#6A7382` (letter-spacing 0.05em) +- Value: Inter Tight 18px/700 `#121826` (schedule) or `#121826` (pay) +- Sub-text: Manrope 13px/400 `#6A7382` + +### Contact/Info Rows + +- Container: radius 12px, border 1px `#D1D5DB`, background `#FFFFFF`, overflow clip +- Row: padding 13px/16px, gap 10px, border-bottom 1px `#F1F3F5` (except last) +- Icon: 16px, stroke `#6A7382` +- Label: Manrope 13px/500 `#6A7382`, width 72px fixed +- Value: Manrope 13px/500 `#121826` (or `#0A39DF` for phone/links) + +### Section Headers + +- Text: Manrope 11px/700, uppercase, letter-spacing 0.06em, color `#6A7382` +- Gap to content below: 10px + +## 3. Screen Structure + +### Artboard Setup + +- Width: 390px (iPhone standard) +- Height: 844px (default), or `fit-content` for scrollable detail pages +- Background: `#FAFBFC` +- Flex column layout, overflow: clip + +### Frame Naming Convention + +``` +-
-- +``` + +Examples: +- `staff-1-1-splash` +- `staff-2-3-personal-information` +- `staff-4-1-my-shifts` +- `staff-5-2-shift-details` +- `shift-5-3-confirmation` + +Section headers use: ` -
` (e.g., `4 - My Shifts`) + +### Status Bar + +- Height: 44px, full width (390px) +- Left: "9:41" text (system font) +- Right: Signal, WiFi, Battery SVG icons (68px wide) + +### Header Back Button + +- Placed below status bar in a combined "Status Bar + Back" frame (390x72px) +- Chevron SVG: 20x20, viewBox 0 0 24 24, stroke `#6A7382`, strokeWidth 2 +- Path: `M15 18L9 12L15 6` +- Back button frame: 390x28px, padding-left: 24px + +### Progress Bar (Onboarding) + +- Container: 342px wide (24px margins), 3px height segments +- Segments: pill radius (999px), gap between +- Filled: `#0A39DF`, Unfilled: `#F1F3F5` + +### Bottom CTA Convention + +- Pinned to bottom using `marginTop: auto` on the CTA container +- Layout: flex row, gap 12px, padding 0 24px +- Back button: 52x52px icon-only button with chevron-left (stroke `#121826`) +- Primary CTA: flex 1, height 52px, radius 14px, bg `#0A39DF` +- Bottom safe padding: 40px (on artboard paddingBottom) + +### Bottom Navigation Bar + +- Full width, padding: 10px top, 28px bottom +- Border-top: 1px `#F1F3F5`, background: `#FFFFFF` +- 5 items: Home, Shifts, Find, Payments, Profile +- Active: icon stroke `#0A39DF`, label Manrope 10px/600 `#0A39DF` +- Inactive: icon stroke `#6A7382`, label Manrope 10px/600 `#6A7382` +- Active icon may have light fill (e.g., `#EBF0FF` on calendar/search) + +## 4. Screen Templates + +### List Screen (My Shifts, Find Shifts) + +``` +Artboard (390x844, bg #FAFBFC) + Status Bar (390x44) + Header Section + Page Title (Display: Inter Tight 28px/700) + Tab/Filter Chips (Small chip variant) + Content + Date Header (Section label style, uppercase) + Shift Cards (12px radius, 1px border #D1D5DB) + Bottom Nav Bar +``` + +### Detail Screen (Shift Details) + +``` +Artboard (390x fit-content, bg #FAFBFC) + Status Bar (390x44) + Header Bar (Back chevron + "Shift Details" title + share icon) + Badges Row (status chips) + Role Title (H1) + Venue (with avatar) + Schedule/Pay Cards (two-column) + Job Description (section label + body text) + Location (card with map + address) + Requirements (section label + checkmark list) + Shift Contact (section label + contact card with rows) + [Optional] Note from Manager (warm bg card) + Bottom CTA (pinned) +``` + +### Onboarding Screen + +``` +Artboard (390x844, bg #FAFBFC, justify: flex-start, paddingBottom: 40px) + Status Bar + Back (390x72) + Progress Bar (342px, 3px segments) + Step Counter ("Step X of Y" - Body Small) + Page Title (H1: Inter Tight 24px/700) + [Optional] Subtitle (Body Default) + Form Content (inputs, chips, sliders) + Bottom CTA (marginTop: auto - back icon + Continue) +``` + +### Confirmation Screen + +``` +Artboard (390x844, bg #FAFBFC) + Status Bar + Centered Content + Success Icon (green circle + checkmark) + Title (Display: Inter Tight 26px/700, centered) + Subtitle (Body Default, centered, #6A7382) + Details Card (border #D1D5DB, rows with label/value pairs) + Bottom CTAs (primary + outline) +``` + +## 5. Workflow Rules + +### Write Incrementally + +Each `write_html` call should produce ONE visual group: +- A header, a card, a single list row, a button bar, a section +- Never batch an entire screen in one call + +### Review Checkpoints + +After every 2-3 modifications, take a screenshot and evaluate: +- **Spacing**: Uneven gaps, cramped groups +- **Typography**: Hierarchy, readability, correct font/weight +- **Contrast**: Text legibility, element distinction +- **Alignment**: Vertical lanes, horizontal alignment +- **Clipping**: Content cut off at edges +- **Token compliance**: All values match design system tokens + +### Color Audit Process + +When updating frames to match the design system: +1. Get computed styles for all text, background, border elements +2. Map old colors to design system tokens: + - Dark navy (`#0F4C81`, `#1A3A5C`) -> Primary `#0A39DF` + - Near-black (`#111827`, `#0F172A`) -> Foreground `#121826` + - Gray variants (`#94A3B8`, `#64748B`, `#475569`) -> Text Secondary `#6A7382` + - Green accents (`#20B486`) -> Primary `#0A39DF` (for pay) or `#059669` (for status) +3. Batch update using `update_styles` with multiple nodeIds per style change +4. Verify with screenshots + +### Structural Consistency + +When creating matching screens (e.g., two shift detail views): +- Use identical section ordering +- Match section header styles (11px/700 uppercase `#6A7382`) +- Use same card/row component patterns +- Maintain consistent padding and gap values + +## 6. SVG Icon Patterns + +### Chevron Left (Back) +```html + + + +``` + +### Map Pin +```html + + + + +``` + +### User (Supervisor) +```html + + + + +``` + +### Phone +```html + + + +``` + +### Checkmark (Requirement Met) +```html + + + + +``` + +### Chip Checkmark +```html + + + + + + + + + +``` + +## 7. Anti-Patterns + +### Colors +- Never use `#0F4C81`, `#1A3A5C` (old navy) - use `#0A39DF` (Primary) +- Never use `#111827`, `#0F172A` - use `#121826` (Foreground) +- Never use `#94A3B8`, `#64748B`, `#475569` - use `#6A7382` (Text Secondary) +- Never use `#20B486` for pay rates - use `#0A39DF` (Primary) +- Never use `#E2E8F0` for card borders - use `#D1D5DB` (Border) + +### Components +- Never use pill radius (999px) for chips or badges - use 8px or 10px +- Never use gradient backgrounds on buttons +- Never mix font families within a role (headings = Inter Tight, body = Manrope) +- Never place back buttons at the bottom of frames - always after status bar +- Never hardcode CTA position - use `marginTop: auto` for bottom pinning + +### Structure +- Never batch an entire screen in one `write_html` call +- Never skip review checkpoints after 2-3 modifications +- Never create frames without following the naming convention +- Never use `justifyContent: space-between` on artboards with many direct children - use `marginTop: auto` on the CTA instead + +## Summary + +**The design file is the source of truth for visual direction.** Every element must use the established tokens: + +1. **Colors**: 7 core tokens + semantic badge colors +2. **Typography**: Inter Tight (headings) + Manrope (body), defined scale +3. **Spacing**: 24px page padding, 16-24px section gaps, 40px bottom safe area +4. **Radii**: 8px (chips/badges), 12px (cards/inputs), 14px (buttons/CTAs) +5. **Components**: Buttons, chips (large/small), inputs, cards, badges, nav bars +6. **Structure**: Status bar > Back > Content > Bottom CTA (pinned) +7. **Naming**: `-
--` + +When in doubt, screenshot an existing screen and match its patterns exactly. diff --git a/.agents/settings.local.json b/.agents/settings.local.json new file mode 100644 index 00000000..ca2f132d --- /dev/null +++ b/.agents/settings.local.json @@ -0,0 +1,23 @@ +{ + "permissions": { + "allow": [ + "Bash(grep:*)", + "mcp__paper__get_basic_info", + "mcp__paper__get_screenshot", + "mcp__paper__get_tree_summary", + "mcp__paper__update_styles", + "mcp__paper__set_text_content", + "mcp__paper__get_computed_styles", + "mcp__paper__finish_working_on_nodes", + "mcp__paper__get_font_family_info", + "mcp__paper__rename_nodes", + "mcp__paper__write_html", + "mcp__paper__get_children", + "mcp__paper__create_artboard", + "mcp__paper__delete_nodes", + "mcp__paper__get_jsx", + "mcp__paper__get_node_info", + "mcp__paper__duplicate_nodes" + ] + } +} diff --git a/.agents/skills/krow-mobile-architecture/SKILL.md b/.agents/skills/krow-mobile-architecture/SKILL.md new file mode 100644 index 00000000..eccc0bb2 --- /dev/null +++ b/.agents/skills/krow-mobile-architecture/SKILL.md @@ -0,0 +1,900 @@ +--- +name: krow-mobile-architecture +description: KROW mobile app Clean Architecture implementation including package structure, dependency rules, feature isolation, BLoC lifecycle management, session handling, and Data Connect connectors pattern. Use this when architecting new mobile features, debugging state management issues, preventing prop drilling, managing BLoC disposal, implementing session stores, or setting up connector repositories. Essential for maintaining architectural integrity across staff and client apps. +--- + +# KROW Mobile Architecture + +This skill defines the authoritative mobile architecture for the KROW platform. All code must strictly adhere to these principles to prevent architectural degradation. + +## When to Use This Skill + +- Architecting new mobile features +- Debugging state management or BLoC lifecycle issues +- Preventing prop drilling in UI code +- Managing session state and authentication +- Implementing Data Connect connector repositories +- Setting up feature modules and dependency injection +- Understanding package boundaries and dependencies +- Refactoring legacy code to Clean Architecture + +## 1. High-Level Architecture + +KROW follows **Clean Architecture** in a **Melos Monorepo**. Dependencies flow **inward** toward the Domain. + +``` +┌─────────────────────────────────────────────────────────┐ +│ Apps (Entry Points) │ +│ • apps/mobile/apps/client │ +│ • apps/mobile/apps/staff │ +│ Role: DI roots, navigation assembly, env config │ +└─────────────────┬───────────────────────────────────────┘ + │ depends on +┌─────────────────▼───────────────────────────────────────┐ +│ Features (Vertical Slices) │ +│ • apps/mobile/packages/features/client/* │ +│ • apps/mobile/packages/features/staff/* │ +│ Role: Pages, BLoCs, Use Cases, Feature Repositories │ +└─────┬───────────────────────────────────────┬───────────┘ + │ depends on │ depends on +┌─────▼────────────────┐ ┌───────▼───────────┐ +│ Design System │ │ Core Localization│ +│ • UI components │ │ • LocaleBloc │ +│ • Theme/colors │ │ • Translations │ +│ • Typography │ │ • ErrorTranslator│ +└──────────────────────┘ └───────────────────┘ + │ both depend on +┌─────────────────▼───────────────────────────────────────┐ +│ Services (Interface Adapters) │ +│ • data_connect: Backend integration, session mgmt │ +│ • core: Extensions, base classes, utilities │ +└─────────────────┬───────────────────────────────────────┘ + │ both depend on +┌─────────────────▼───────────────────────────────────────┐ +│ Domain (Stable Core) │ +│ • Entities (immutable data models) │ +│ • Failures (domain-specific errors) │ +│ • Pure Dart only, zero Flutter dependencies │ +└─────────────────────────────────────────────────────────┘ +``` + +**Critical Rule:** Dependencies point INWARD only. Domain knows nothing about the outer layers. + +## 2. Package Structure & Responsibilities + +### 2.1 Apps (`apps/mobile/apps/`) + +**Role:** Application entry points and DI roots + +**Responsibilities:** +- Initialize Flutter Modular +- Assemble features into navigation tree +- Inject concrete implementations (from `data_connect`) into features +- Configure environment-specific settings (dev/stage/prod) +- Initialize session management + +**Structure:** +``` +apps/mobile/apps/staff/ +├── lib/ +│ ├── main.dart # Entry point, session initialization +│ ├── app_module.dart # Root module, imports features +│ ├── app_widget.dart # MaterialApp setup +│ └── src/ +│ ├── navigation/ # Typed navigators +│ └── widgets/ # SessionListener wrapper +└── pubspec.yaml +``` + +**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:** +``` +features/staff/profile/ +├── lib/ +│ ├── src/ +│ │ ├── domain/ +│ │ │ ├── repositories/ # Repository interfaces +│ │ │ │ └── profile_repository_interface.dart +│ │ │ └── usecases/ # Application logic +│ │ │ └── get_profile_usecase.dart +│ │ ├── data/ +│ │ │ └── repositories_impl/ # Repository concrete classes +│ │ │ └── profile_repository_impl.dart +│ │ └── presentation/ +│ │ ├── blocs/ # State management +│ │ │ └── profile_cubit.dart +│ │ ├── pages/ # Screens (StatelessWidget preferred) +│ │ │ └── profile_page.dart +│ │ └── widgets/ # Reusable UI components +│ │ └── profile_header.dart +│ └── profile_feature.dart # Barrel file (public API only) +└── pubspec.yaml +``` + +**Key Principles:** +- **Presentation:** UI Pages and Widgets, BLoCs/Cubits for state +- **Application:** Use Cases (business logic orchestration) +- **Data:** Repository implementations (backend integration) +- **Pages as StatelessWidget:** Move state to BLoCs for better performance and testability + +**RESTRICTION:** Features MUST NOT import other features. Communication happens via: +- Shared domain entities +- Session stores (`StaffSessionStore`, `ClientSessionStore`) +- Navigation via Modular +- Data Connect connector repositories + +### 2.3 Domain (`apps/mobile/packages/domain`) + +**Role:** The stable, pure heart of the system + +**Responsibilities:** +- Define **Entities** (immutable data models using Data Classes or Freezed) +- Define **Failures** (domain-specific error types) + +**Structure:** +``` +domain/ +├── lib/ +│ └── src/ +│ ├── entities/ +│ │ ├── user.dart +│ │ ├── staff.dart +│ │ └── shift.dart +│ └── failures/ +│ ├── failure.dart # Base class +│ ├── auth_failure.dart +│ └── network_failure.dart +└── pubspec.yaml +``` + +**Example Entity:** +```dart +import 'package:equatable/equatable.dart'; + +class Staff extends Equatable { + final String id; + final String name; + final String email; + final StaffStatus status; + + const Staff({ + required this.id, + required this.name, + required this.email, + required this.status, + }); + + @override + List get props => [id, name, email, status]; +} +``` + +**RESTRICTION:** +- NO Flutter dependencies (no `import 'package:flutter/material.dart'`) +- NO `json_annotation` or serialization code +- Only `equatable` for value equality +- Pure Dart only + +### 2.4 Data Connect (`apps/mobile/packages/data_connect`) + +**Role:** Interface Adapter for Backend Access + +**Responsibilities:** +- Centralized connector repositories (see Data Connect Connectors Pattern section) +- Implement Firebase Data Connect service layer +- Map Domain Entities ↔ Data Connect generated code +- Handle Firebase exceptions → domain failures +- Provide `DataConnectService` with session management + +**Structure:** +``` +data_connect/ +├── lib/ +│ ├── src/ +│ │ ├── services/ +│ │ │ ├── data_connect_service.dart # Core service +│ │ │ └── mixins/ +│ │ │ └── session_handler_mixin.dart +│ │ ├── connectors/ # Connector pattern (see below) +│ │ │ ├── staff/ +│ │ │ │ ├── domain/ +│ │ │ │ │ ├── repositories/ +│ │ │ │ │ │ └── staff_connector_repository.dart +│ │ │ │ │ └── usecases/ +│ │ │ │ │ └── get_profile_completion_usecase.dart +│ │ │ │ └── data/ +│ │ │ │ └── repositories/ +│ │ │ │ └── staff_connector_repository_impl.dart +│ │ │ ├── order/ +│ │ │ └── shifts/ +│ │ └── session/ +│ │ ├── staff_session_store.dart +│ │ └── client_session_store.dart +│ └── krow_data_connect.dart # Exports +└── pubspec.yaml +``` + +**RESTRICTION:** +- NO feature-specific logic +- Connectors are domain-neutral and reusable +- All queries follow Clean Architecture (domain interfaces → data implementations) + +### 2.5 Design System (`apps/mobile/packages/design_system`) + +**Role:** Visual language and component library + +**Responsibilities:** +- Theme definitions (`UiColors`, `UiTypography`) +- UI constants (`spacingL`, `radiusM`, etc.) +- Shared widgets (if reused across multiple features) +- Assets (icons, images, fonts) + +**Structure:** +``` +design_system/ +├── lib/ +│ └── src/ +│ ├── ui_colors.dart +│ ├── ui_typography.dart +│ ├── ui_icons.dart +│ ├── ui_constants.dart +│ ├── ui_theme.dart # ThemeData factory +│ └── widgets/ # Shared UI components +│ └── custom_button.dart +└── assets/ + ├── icons/ + └── images/ +``` + +**RESTRICTION:** +- Dumb widgets ONLY (no state management) +- NO business logic +- Colors and typography are IMMUTABLE (no feature can override) + +### 2.6 Core Localization (`apps/mobile/packages/core_localization`) + +**Role:** Centralized i18n management + +**Responsibilities:** +- Define all user-facing strings in `l10n/` +- Provide `LocaleBloc` for locale state management +- Export `TranslationProvider` for `context.strings` access +- Map domain failures to localized error messages via `ErrorTranslator` + +**Feature Integration:** +```dart +// Features access strings +Text(context.strings.loginButton) + +// BLoCs emit domain failures (not strings) +emit(AuthError(InvalidCredentialsFailure())); + +// UI translates failures to localized messages +final message = ErrorTranslator.translate(failure, context.strings); +``` + +**App Setup:** +```dart +// App imports LocalizationModule +class AppModule extends Module { + @override + List get imports => [LocalizationModule()]; +} + +// Wrap app with providers +BlocProvider( + create: (_) => Modular.get(), + child: TranslationProvider( + child: MaterialApp.router(...), + ), +) +``` + +### 2.7 Core (`apps/mobile/packages/core`) + +**Role:** Cross-cutting concerns + +**Responsibilities:** +- Extension methods (NavigationExtensions, ListExtensions, etc.) +- Base classes (UseCase, Failure, BlocErrorHandler) +- Logger configuration +- Result types for functional error handling + +## 3. Dependency Direction Rules + +1. **Domain Independence:** `domain` knows NOTHING about outer layers + - Defines *what* needs to be done, not *how* + - Pure Dart, zero Flutter dependencies + - Stable contracts that rarely change + +2. **UI Agnosticism:** Features depend on `design_system` for UI and `domain` for logic + - Features do NOT know about Firebase or backend details + - Backend changes don't affect feature implementation + +3. **Data Isolation:** `data_connect` depends on `domain` to know interfaces + - Implements domain repository interfaces + - Maps backend models to domain entities + - Does NOT know about UI + +**Dependency Flow:** +``` +Apps → Features → Design System + → Core Localization + → Data Connect → Domain + → Core +``` + +## 4. Data Connect Service & 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 <5 minutes to expiry) +- Firebase auth state listening +- Role-based access validation +- Session state stream emissions +- 3-attempt retry with exponential backoff (1s → 2s → 4s) + +**Key Method:** +```dart +// Call once on app startup +DataConnectService.instance.initializeAuthListener( + allowedRoles: ['STAFF', 'BOTH'], // or ['CLIENT', 'BUSINESS', 'BOTH'] +); +``` + +### 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 + +**Usage:** +```dart +// main.dart +runApp( + SessionListener( // ← Critical wrapper + child: ModularApp(module: AppModule(), child: AppWidget()), + ), +); +``` + +### 4.3 Repository Pattern with Data Connect + +**Step 1:** Define interface in feature domain: +```dart +// features/staff/profile/lib/src/domain/repositories/ +abstract interface class ProfileRepositoryInterface { + Future getProfile(String id); +} +``` + +**Step 2:** Implement using `DataConnectService.run()`: +```dart +// features/staff/profile/lib/src/data/repositories_impl/ +class ProfileRepositoryImpl implements ProfileRepositoryInterface { + final DataConnectService _service = DataConnectService.instance; + + @override + Future getProfile(String id) async { + return await _service.run(() async { + final response = await _service.connector + .getStaffById(id: id) + .execute(); + return _mapToStaff(response.data.staff); + }); + } +} +``` + +**Benefits of `_service.run()`:** +- ✅ Auto validates user is authenticated +- ✅ Refreshes token if <5 min to expiry +- ✅ Executes the query +- ✅ 3-attempt retry with exponential backoff +- ✅ Maps exceptions to domain failures + +### 4.4 Session Store Pattern + +After successful auth, populate session stores: + +**Staff App:** +```dart +StaffSessionStore.instance.setSession( + StaffSession( + user: user, + staff: staff, + ownerId: ownerId, + ), +); +``` + +**Client App:** +```dart +ClientSessionStore.instance.setSession( + ClientSession( + user: user, + business: business, + ), +); +``` + +**Lazy Loading:** If session is null, fetch from backend and update: +```dart +final session = StaffSessionStore.instance.session; +if (session?.staff == null) { + final staff = await getStaffById(session!.user.uid); + StaffSessionStore.instance.setSession( + session.copyWith(staff: staff), + ); +} +``` + +## 5. Feature Isolation & Communication + +### Zero Direct Imports + +```dart +// ❌ FORBIDDEN +import 'package:staff_profile/staff_profile.dart'; // in another feature + +// ✅ ALLOWED +import 'package:krow_domain/krow_domain.dart'; // shared domain +import 'package:krow_core/krow_core.dart'; // shared utilities +import 'package:design_system/design_system.dart'; // shared UI +``` + +### Navigation: Typed Navigators with Safe Extensions + +**Safe Navigation Extensions** (from `core` package): +```dart +extension NavigationExtensions on IModularNavigator { + /// Safely navigate with fallback to home + Future safeNavigate(String route) async { + try { + await navigate(route); + } catch (e) { + await navigate('/home'); // Fallback + } + } + + /// Safely push with fallback to home + Future safePush(String route) async { + try { + return await pushNamed(route); + } catch (e) { + await navigate('/home'); + return null; + } + } + + /// Safely pop with guard against empty stack + void popSafe() { + if (canPop()) { + pop(); + } else { + navigate('/home'); + } + } +} +``` + +**Typed Navigators:** +```dart +// apps/mobile/apps/staff/lib/src/navigation/staff_navigator.dart +extension StaffNavigator on IModularNavigator { + Future toStaffHome() => safeNavigate(StaffPaths.home); + + Future toShiftDetails(String shiftId) => + safePush('${StaffPaths.shifts}/$shiftId'); + + Future toProfileEdit() => safePush(StaffPaths.profileEdit); +} +``` + +**Usage in Features:** +```dart +// ✅ CORRECT +Modular.to.toStaffHome(); +Modular.to.toShiftDetails(shiftId: '123'); +Modular.to.popSafe(); + +// ❌ AVOID +Modular.to.navigate('/home'); // No safety +Navigator.push(...); // No Modular integration +``` + +### Data Sharing Patterns + +Features don't share state directly. Use: + +1. **Domain Repositories:** Centralized data sources +2. **Session Stores:** `StaffSessionStore`, `ClientSessionStore` for app-wide context +3. **Event Streams:** If needed, via `DataConnectService` streams +4. **Navigation Arguments:** Pass IDs, not full objects + +## 6. App-Specific Session Management + +### Staff App + +```dart +// main.dart +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + DataConnectService.instance.initializeAuthListener( + allowedRoles: ['STAFF', 'BOTH'], + ); + + runApp( + SessionListener( + child: ModularApp(module: StaffAppModule(), child: StaffApp()), + ), + ); +} +``` + +**Session Store:** `StaffSessionStore` +- Fields: `user`, `staff`, `ownerId` +- Lazy load: `getStaffById()` if staff is null + +**Navigation:** +- Authenticated → `Modular.to.toStaffHome()` +- Unauthenticated → `Modular.to.toInitialPage()` + +### Client App + +```dart +// main.dart +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + DataConnectService.instance.initializeAuthListener( + allowedRoles: ['CLIENT', 'BUSINESS', 'BOTH'], + ); + + runApp( + SessionListener( + child: ModularApp(module: ClientAppModule(), child: ClientApp()), + ), + ); +} +``` + +**Session Store:** `ClientSessionStore` +- Fields: `user`, `business` +- Lazy load: `getBusinessById()` if business is null + +**Navigation:** +- Authenticated → `Modular.to.toClientHome()` +- Unauthenticated → `Modular.to.toInitialPage()` + +## 7. Data Connect Connectors Pattern + +**Problem:** Without connectors, each feature duplicates backend queries. + +**Solution:** Centralize all backend queries in `data_connect/connectors/`. + +### Structure + +Mirror backend connector structure: + +``` +data_connect/lib/src/connectors/ +├── staff/ +│ ├── domain/ +│ │ ├── repositories/ +│ │ │ └── staff_connector_repository.dart # Interface +│ │ └── usecases/ +│ │ └── get_profile_completion_usecase.dart +│ └── data/ +│ └── repositories/ +│ └── staff_connector_repository_impl.dart # Implementation +├── order/ +├── shifts/ +└── user/ +``` + +**Maps to backend:** +``` +backend/dataconnect/connector/ +├── staff/ +├── order/ +├── shifts/ +└── user/ +``` + +### Clean Architecture in Connectors + +**Domain Interface:** +```dart +// staff_connector_repository.dart +abstract interface class StaffConnectorRepository { + Future getProfileCompletion(); + Future getStaffById(String id); +} +``` + +**Use Case:** +```dart +// get_profile_completion_usecase.dart +class GetProfileCompletionUseCase { + final StaffConnectorRepository _repository; + + GetProfileCompletionUseCase({required StaffConnectorRepository repository}) + : _repository = repository; + + Future call() => _repository.getProfileCompletion(); +} +``` + +**Data Implementation:** +```dart +// staff_connector_repository_impl.dart +class StaffConnectorRepositoryImpl implements StaffConnectorRepository { + final DataConnectService _service; + + @override + Future getProfileCompletion() async { + return _service.run(() async { + final staffId = await _service.getStaffId(); + final response = await _service.connector + .getStaffProfileCompletion(id: staffId) + .execute(); + + return _isProfileComplete(response); + }); + } +} +``` + +### Feature Integration + +**Step 1:** Feature registers connector repository: +```dart +// staff_main_module.dart +class StaffMainModule extends Module { + @override + void binds(Injector i) { + i.addLazySingleton( + StaffConnectorRepositoryImpl.new, + ); + + i.addLazySingleton( + () => GetProfileCompletionUseCase( + repository: i.get(), + ), + ); + + i.addLazySingleton( + () => StaffMainCubit( + getProfileCompletionUsecase: i.get(), + ), + ); + } +} +``` + +**Step 2:** BLoC uses it: +```dart +class StaffMainCubit extends Cubit { + final GetProfileCompletionUseCase _getProfileCompletionUsecase; + + Future loadProfileCompletion() async { + final isComplete = await _getProfileCompletionUsecase(); + emit(state.copyWith(isProfileComplete: isComplete)); + } +} +``` + +### Benefits + +✅ **No Duplication** - Query implemented once, used by many features +✅ **Single Source of Truth** - Backend change → update one place +✅ **Reusability** - Any feature can use any connector +✅ **Testability** - Mock connector repo to test features +✅ **Scalability** - Easy to add connectors as backend grows + +## 8. Avoiding Prop Drilling: Direct BLoC Access + +### The Problem + +Passing data through intermediate widgets creates maintenance burden: + +```dart +// ❌ BAD: Prop drilling +ProfilePage(status: status) + → ProfileHeader(status: status) + → ProfileLevelBadge(status: status) // Only widget that needs it +``` + +### The Solution: BlocBuilder in Leaf Widgets + +```dart +// ✅ GOOD: Direct BLoC access +class ProfileLevelBadge extends StatelessWidget { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.profile == null) return const SizedBox.shrink(); + + final level = _mapStatusToLevel(state.profile!.status); + return LevelBadgeUI(level: level); + }, + ); + } +} +``` + +### Guidelines + +1. **Leaf Widgets Access BLoC:** Widgets needing specific data should use `BlocBuilder` +2. **Container Widgets Stay Simple:** Parent widgets only manage layout +3. **No Unnecessary Props:** Don't pass data to intermediate widgets +4. **Single Responsibility:** Each widget has one reason to exist + +**Decision Tree:** +``` +Does this widget need data? +├─ YES, leaf widget → Use BlocBuilder +├─ YES, container → Use BlocBuilder in child +└─ NO → Don't add prop +``` + +## 9. BLoC Lifecycle & State Emission Safety + +### The Problem: StateError After Dispose + +When async operations complete after BLoC is closed: +``` +StateError: Cannot emit new states after calling close +``` + +**Root Causes:** +1. Transient BLoCs created with `BlocProvider(create:)` → disposed prematurely +2. Multiple BlocProviders disposing same singleton +3. User navigates away during async operation + +### The Solution: Singleton BLoCs + Safe Emit + +#### Step 1: Register as Singleton + +```dart +// ✅ GOOD: Singleton registration +i.addLazySingleton( + () => ProfileCubit(useCase1, useCase2), +); + +// ❌ BAD: Creates new instance each time +i.add(ProfileCubit.new); +``` + +#### Step 2: Use BlocProvider.value() + +```dart +// ✅ GOOD: Reuse singleton +final cubit = Modular.get(); +BlocProvider.value( + value: cubit, + child: MyWidget(), +) + +// ❌ BAD: Creates duplicate +BlocProvider( + create: (_) => Modular.get(), + child: MyWidget(), +) +``` + +#### Step 3: Safe Emit with BlocErrorHandler + +**Location:** `apps/mobile/packages/core/lib/src/presentation/mixins/bloc_error_handler.dart` + +```dart +mixin BlocErrorHandler on Cubit { + void _safeEmit(void Function(S) emit, S state) { + try { + emit(state); + } on StateError catch (e) { + developer.log( + 'Could not emit state: ${e.message}. Bloc may have been disposed.', + name: runtimeType.toString(), + ); + } + } +} +``` + +**Usage:** +```dart +class ProfileCubit extends Cubit with BlocErrorHandler { + Future loadProfile() async { + emit(state.copyWith(status: ProfileStatus.loading)); + + await handleError( + emit: emit, + action: () async { + final profile = await getProfile(); + emit(state.copyWith(status: ProfileStatus.loaded, profile: profile)); + // ✅ Safe even if BLoC disposed + }, + onError: (errorKey) => state.copyWith(status: ProfileStatus.error), + ); + } +} +``` + +### Pattern Summary + +| Pattern | When to Use | Risk | +|---------|------------|------| +| Singleton + BlocProvider.value() | Long-lived features | Low | +| Transient + BlocProvider(create:) | Temporary widgets | Medium | +| Direct BlocBuilder | Leaf widgets | Low | + +## 10. Anti-Patterns to Avoid + +❌ **Feature imports feature** +```dart +import 'package:staff_profile/staff_profile.dart'; // in another feature +``` + +❌ **Business logic in BLoC** +```dart +on((event, emit) { + if (event.email.isEmpty) { // ← Use case responsibility + emit(AuthError('Email required')); + } +}); +``` + +❌ **Direct Data Connect in features** +```dart +final response = await FirebaseDataConnect.instance.query(); // ← Use repository +``` + +❌ **Global state variables** +```dart +User? currentUser; // ← Use SessionStore +``` + +❌ **Direct Navigator.push** +```dart +Navigator.push(context, MaterialPageRoute(...)); // ← Use Modular +``` + +❌ **Hardcoded navigation** +```dart +Modular.to.navigate('/profile'); // ← Use safe extensions +``` + +## Summary + +The architecture enforces: +- **Clean Architecture** with strict layer boundaries +- **Feature Isolation** via zero cross-feature imports +- **Session Management** via DataConnectService and SessionListener +- **Connector Pattern** for reusable backend queries +- **BLoC Lifecycle** safety with singletons and safe emit +- **Navigation Safety** with typed navigators and fallbacks + +When implementing features: +1. Follow package structure strictly +2. Use connector repositories for backend access +3. Register BLoCs as singletons with `.value()` +4. Use safe navigation extensions +5. Avoid prop drilling with direct BLoC access +6. Keep domain pure and stable + +Architecture is not negotiable. When in doubt, refer to existing well-structured features or ask for clarification. diff --git a/.agents/skills/krow-mobile-design-system/SKILL.md b/.agents/skills/krow-mobile-design-system/SKILL.md new file mode 100644 index 00000000..2f6d6a40 --- /dev/null +++ b/.agents/skills/krow-mobile-design-system/SKILL.md @@ -0,0 +1,717 @@ +--- +name: krow-mobile-design-system +description: KROW mobile design system usage rules covering colors, typography, icons, spacing, and UI component patterns. Use this when implementing UI in KROW mobile features, matching POC designs to production, creating themed widgets, enforcing visual consistency, or reviewing UI code compliance. Prevents hardcoded values and ensures brand consistency across staff and client apps. Critical for maintaining immutable design tokens. +--- + +# KROW Mobile Design System Usage + +This skill defines mandatory standards for UI implementation using the shared `apps/mobile/packages/design_system`. All UI must consume design system tokens exclusively. + +## When to Use This Skill + +- Implementing any UI in mobile features +- Migrating POC/prototype designs to production +- Creating new themed widgets or components +- Reviewing UI code for design system compliance +- Matching colors and typography from designs +- Adding icons, spacing, or layout elements +- Setting up theme configuration in apps +- Refactoring UI code with hardcoded values + +## Core Principle + +**Design tokens (colors, typography, spacing) are IMMUTABLE and defined centrally.** + +Features consume tokens but NEVER modify them. The design system maintains visual coherence across all apps. + +## 1. Design System Ownership + +### Centralized Authority + +- `apps/mobile/packages/design_system` owns: + - All brand assets + - Colors and semantic color mappings + - Typography and font configurations + - Core UI components + - Icons and images + - Spacing, radius, elevation constants + +### No Local Overrides + +**✅ CORRECT:** +```dart +// Feature uses design system +import 'package:design_system/design_system.dart'; + +Container( + color: UiColors.background, + padding: EdgeInsets.all(UiConstants.spacingL), + child: Text( + 'Hello', + style: UiTypography.display1m, + ), +) +``` + +**❌ FORBIDDEN:** +```dart +// ❌ Custom colors in feature +const myBlue = Color(0xFF1A2234); + +// ❌ Custom text styles in feature +const myStyle = TextStyle(fontSize: 24, fontWeight: FontWeight.bold); + +// ❌ Theme overrides in feature +Theme( + data: ThemeData(primaryColor: Colors.blue), + child: MyWidget(), +) +``` + +### Extension Policy + +If a required style is missing: +1. **FIRST:** Add it to `design_system` following existing patterns +2. **THEN:** Use it in your feature + +**DO NOT** create temporary workarounds with hardcoded values. + +## 2. Package Structure + +``` +apps/mobile/packages/design_system/ +├── lib/ +│ ├── src/ +│ │ ├── ui_colors.dart # Color tokens +│ │ ├── ui_typography.dart # Text styles +│ │ ├── ui_icons.dart # Icon exports +│ │ ├── ui_constants.dart # Spacing, radius, elevation +│ │ ├── ui_theme.dart # ThemeData factory +│ │ └── widgets/ # Shared UI components +│ │ ├── custom_button.dart +│ │ └── custom_app_bar.dart +│ └── design_system.dart # Public exports +├── assets/ +│ ├── icons/ +│ ├── images/ +│ └── fonts/ +└── pubspec.yaml +``` + +## 3. Colors Usage Rules + +### Strict Protocol + +**✅ DO:** +```dart +// Use UiColors for all color needs +Container(color: UiColors.background) +Text('Hello', style: TextStyle(color: UiColors.foreground)) +Icon(Icons.home, color: UiColors.primary) +``` + +**❌ DON'T:** +```dart +// ❌ Hardcoded hex colors +Container(color: Color(0xFF1A2234)) + +// ❌ Material color constants +Container(color: Colors.blue) + +// ❌ Opacity on hardcoded colors +Container(color: Color(0xFF1A2234).withOpacity(0.5)) +``` + +### Available Color Categories + +**Brand Colors:** +- `UiColors.primary` - Main brand color +- `UiColors.secondary` - Secondary brand color +- `UiColors.accent` - Accent highlights + +**Semantic Colors:** +- `UiColors.background` - Page background +- `UiColors.foreground` - Primary text color +- `UiColors.card` - Card/container background +- `UiColors.border` - Border colors +- `UiColors.mutedForeground` - Secondary text + +**Status Colors:** +- `UiColors.success` - Success states +- `UiColors.warning` - Warning states +- `UiColors.error` - Error states +- `UiColors.info` - Information states + +### Color Matching from POCs + +When migrating POC designs: + +1. **Find closest match** in `UiColors` +2. **Use existing color** even if slightly different +3. **DO NOT add new colors** without design team approval + +**Example Process:** +```dart +// POC has: Color(0xFF2C3E50) +// Find closest: UiColors.background or UiColors.card +// Use: UiColors.card + +// POC has: Color(0xFF27AE60) +// Find closest: UiColors.success +// Use: UiColors.success +``` + +### Theme Access + +Colors can also be accessed via theme: +```dart +// Both are valid: +Container(color: UiColors.primary) +Container(color: Theme.of(context).colorScheme.primary) +``` + +## 4. Typography Usage Rules + +### Strict Protocol + +**✅ DO:** +```dart +// Use UiTypography for all text +Text('Title', style: UiTypography.display1m) +Text('Body', style: UiTypography.body1r) +Text('Label', style: UiTypography.caption1m) +``` + +**❌ DON'T:** +```dart +// ❌ Custom TextStyle +Text('Title', style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, +)) + +// ❌ Manual font configuration +Text('Body', style: TextStyle( + fontFamily: 'Inter', + fontSize: 16, +)) + +// ❌ Modifying existing styles inline +Text('Title', style: UiTypography.display1m.copyWith( + fontSize: 28, // ← Don't override size +)) +``` + +### Available Typography Styles + +**Display Styles (Large Headers):** +- `UiTypography.display1m` - Display Medium +- `UiTypography.display1sb` - Display Semi-Bold +- `UiTypography.display1b` - Display Bold + +**Heading Styles:** +- `UiTypography.heading1m` - H1 Medium +- `UiTypography.heading1sb` - H1 Semi-Bold +- `UiTypography.heading1b` - H1 Bold +- `UiTypography.heading2m` - H2 Medium +- `UiTypography.heading2sb` - H2 Semi-Bold + +**Body Styles:** +- `UiTypography.body1r` - Body Regular +- `UiTypography.body1m` - Body Medium +- `UiTypography.body1sb` - Body Semi-Bold +- `UiTypography.body2r` - Body 2 Regular + +**Caption/Label Styles:** +- `UiTypography.caption1m` - Caption Medium +- `UiTypography.caption1sb` - Caption Semi-Bold +- `UiTypography.label1m` - Label Medium + +### Allowed Customizations + +**✅ ALLOWED (Color Only):** +```dart +// You MAY change color +Text( + 'Title', + style: UiTypography.display1m.copyWith( + color: UiColors.error, // ← OK + ), +) +``` + +**❌ FORBIDDEN (Size, Weight, Family):** +```dart +// ❌ Don't change size +Text( + 'Title', + style: UiTypography.display1m.copyWith(fontSize: 28), +) + +// ❌ Don't change weight +Text( + 'Title', + style: UiTypography.display1m.copyWith(fontWeight: FontWeight.w900), +) + +// ❌ Don't change family +Text( + 'Title', + style: UiTypography.display1m.copyWith(fontFamily: 'Roboto'), +) +``` + +### Typography Matching from POCs + +When migrating: +1. Identify text role (heading, body, caption) +2. Find closest matching style in `UiTypography` +3. Use existing style even if size/weight differs slightly + +## 5. Icons Usage Rules + +### Strict Protocol + +**✅ DO:** +```dart +// Use UiIcons +Icon(UiIcons.home) +Icon(UiIcons.profile) +Icon(UiIcons.chevronLeft) +``` + +**❌ DON'T:** +```dart +// ❌ Direct icon library imports +import 'package:lucide_icons/lucide_icons.dart'; +Icon(LucideIcons.home) + +// ❌ Font Awesome direct +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +FaIcon(FontAwesomeIcons.house) +``` + +### Why Centralize Icons? + +1. **Consistency:** Same icon for same action everywhere +2. **Branding:** Unified icon set with consistent stroke weight +3. **Swappability:** Change icon library in one place + +### Icon Libraries + +Design system uses: +- `typedef _IconLib = LucideIcons;` (primary) +- `typedef _IconLib2 = FontAwesomeIcons;` (secondary) + +**Features MUST NOT import these directly.** + +### Adding New Icons + +If icon missing: +1. Add to `ui_icons.dart`: +```dart +class UiIcons { + static const home = _IconLib.home; + static const newIcon = _IconLib.newIcon; // Add here +} +``` +2. Use in feature: +```dart +Icon(UiIcons.newIcon) +``` + +## 6. Spacing & Layout Constants + +### Strict Protocol + +**✅ DO:** +```dart +// Use UiConstants for spacing +Padding(padding: EdgeInsets.all(UiConstants.spacingL)) +SizedBox(height: UiConstants.spacingM) +Container( + padding: EdgeInsets.symmetric( + horizontal: UiConstants.spacingL, + vertical: UiConstants.spacingM, + ), +) + +// Use UiConstants for radius +Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(UiConstants.radiusM), + ), +) + +// Use UiConstants for elevation +elevation: UiConstants.elevationLow +``` + +**❌ DON'T:** +```dart +// ❌ Magic numbers +Padding(padding: EdgeInsets.all(16.0)) +SizedBox(height: 24.0) +BorderRadius.circular(8.0) +elevation: 2.0 +``` + +### Available Constants + +**Spacing:** +```dart +UiConstants.spacingXs // Extra small +UiConstants.spacingS // Small +UiConstants.spacingM // Medium +UiConstants.spacingL // Large +UiConstants.spacingXl // Extra large +UiConstants.spacing2xl // 2x Extra large +``` + +**Border Radius:** +```dart +UiConstants.radiusS // Small +UiConstants.radiusM // Medium +UiConstants.radiusL // Large +UiConstants.radiusXl // Extra large +UiConstants.radiusFull // Fully rounded +``` + +**Elevation:** +```dart +UiConstants.elevationNone +UiConstants.elevationLow +UiConstants.elevationMedium +UiConstants.elevationHigh +``` + +## 7. Smart Widgets Usage + +### When to Use + +- **Prefer standard Flutter Material widgets** styled via theme +- **Use design system widgets** for non-standard patterns +- **Create new widgets** in design system if reused >3 features + +### Navigation in Widgets + +Widgets with navigation MUST use safe methods: + +**✅ CORRECT:** +```dart +// In UiAppBar back button: +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/krow_core.dart'; + +IconButton( + icon: Icon(UiIcons.chevronLeft), + onPressed: () => Modular.to.popSafe(), // ← Safe pop +) +``` + +**❌ FORBIDDEN:** +```dart +// ❌ Direct Navigator +IconButton( + icon: Icon(UiIcons.chevronLeft), + onPressed: () => Navigator.pop(context), +) + +// ❌ Unsafe Modular +IconButton( + icon: Icon(UiIcons.chevronLeft), + onPressed: () => Modular.to.pop(), // Can crash +) +``` + +### Composition Over Inheritance + +**✅ CORRECT:** +```dart +// Compose standard widgets +Container( + padding: EdgeInsets.all(UiConstants.spacingL), + decoration: BoxDecoration( + color: UiColors.card, + borderRadius: BorderRadius.circular(UiConstants.radiusM), + ), + child: Column( + children: [ + Text('Title', style: UiTypography.heading1sb), + SizedBox(height: UiConstants.spacingM), + Text('Body', style: UiTypography.body1r), + ], + ), +) +``` + +**❌ AVOID:** +```dart +// ❌ Deep custom widget hierarchies +class CustomCard extends StatelessWidget { + // Complex custom implementation +} +``` + +## 8. Theme Configuration + +### App Setup + +Apps initialize theme ONCE in root MaterialApp: + +**✅ CORRECT:** +```dart +// apps/mobile/apps/staff/lib/app_widget.dart +import 'package:design_system/design_system.dart'; + +class StaffApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MaterialApp.router( + theme: StaffTheme.light, // ← Design system theme + darkTheme: StaffTheme.dark, // ← Optional dark mode + themeMode: ThemeMode.system, + // ... + ); + } +} +``` + +**❌ FORBIDDEN:** +```dart +// ❌ Custom theme in app +MaterialApp.router( + theme: ThemeData( + primaryColor: Colors.blue, // ← NO! + ), +) + +// ❌ Theme override in feature +Theme( + data: ThemeData(...), + child: MyFeatureWidget(), +) +``` + +### Accessing Theme + +**Both methods valid:** +```dart +// Method 1: Direct design system import +import 'package:design_system/design_system.dart'; +Text('Hello', style: UiTypography.body1r) + +// Method 2: Via theme context +Text('Hello', style: Theme.of(context).textTheme.bodyMedium) +``` + +**Prefer Method 1** for explicit type safety. + +## 9. POC → Production Workflow + +### Step 1: Implement Structure (POC Matching) + +Implement UI layout exactly matching POC: +```dart +// Temporary: Match POC visually +Container( + color: Color(0xFF1A2234), // ← POC color + padding: EdgeInsets.all(16.0), // ← POC spacing + child: Text( + 'Title', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), // ← POC style + ), +) +``` + +**Purpose:** Ensure visual parity with POC before refactoring. + +### Step 2: Architecture Refactor + +Move to Clean Architecture: +- Extract business logic to use cases +- Move state management to BLoCs +- Implement repository pattern +- Use dependency injection + +### Step 3: Design System Integration + +Replace hardcoded values: +```dart +// Production: Design system tokens +Container( + color: UiColors.background, // ← Found closest match + padding: EdgeInsets.all(UiConstants.spacingL), // ← Used constant + child: Text( + 'Title', + style: UiTypography.heading1sb, // ← Matched typography + ), +) +``` + +**Color Matching:** +- POC `#1A2234` → `UiColors.background` +- POC `#3498DB` → `UiColors.primary` +- POC `#27AE60` → `UiColors.success` + +**Typography Matching:** +- POC `24px bold` → `UiTypography.heading1sb` +- POC `16px regular` → `UiTypography.body1r` +- POC `14px medium` → `UiTypography.caption1m` + +**Spacing Matching:** +- POC `16px` → `UiConstants.spacingL` +- POC `8px` → `UiConstants.spacingM` +- POC `4px` → `UiConstants.spacingS` + +## 10. Anti-Patterns & Common Mistakes + +### ❌ Magic Numbers +```dart +// BAD +EdgeInsets.all(12.0) +SizedBox(height: 24.0) +BorderRadius.circular(8.0) + +// GOOD +EdgeInsets.all(UiConstants.spacingM) +SizedBox(height: UiConstants.spacingL) +BorderRadius.circular(UiConstants.radiusM) +``` + +### ❌ Local Themes +```dart +// BAD +Theme( + data: ThemeData(primaryColor: Colors.blue), + child: MyWidget(), +) + +// GOOD +// Use global theme defined in app +``` + +### ❌ Hex Hunting +```dart +// BAD: Copy-paste from Figma +Container(color: Color(0xFF3498DB)) + +// GOOD: Find matching design system color +Container(color: UiColors.primary) +``` + +### ❌ Direct Icon Library +```dart +// BAD +import 'package:lucide_icons/lucide_icons.dart'; +Icon(LucideIcons.home) + +// GOOD +Icon(UiIcons.home) +``` + +### ❌ Custom Text Styles +```dart +// BAD +Text('Title', style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + fontFamily: 'Inter', +)) + +// GOOD +Text('Title', style: UiTypography.heading1sb) +``` + +## 11. Design System Review Checklist + +Before merging UI code: + +### ✅ Design System Compliance +- [ ] No hardcoded `Color(...)` or `0xFF...` hex values +- [ ] No custom `TextStyle(...)` definitions +- [ ] All spacing uses `UiConstants.spacing*` +- [ ] All radius uses `UiConstants.radius*` +- [ ] All elevation uses `UiConstants.elevation*` +- [ ] All icons from `UiIcons`, not direct library imports +- [ ] Theme consumed from design system, no local overrides +- [ ] Layout matches POC intent using design system primitives + +### ✅ Architecture Compliance +- [ ] No business logic in widgets +- [ ] State managed by BLoCs +- [ ] Navigation uses Modular safe extensions +- [ ] Localization used for all text (no hardcoded strings) +- [ ] No direct Data Connect queries in widgets + +### ✅ Code Quality +- [ ] Widget build methods concise (<50 lines) +- [ ] Complex widgets extracted to separate files +- [ ] Meaningful widget names +- [ ] Doc comments on reusable widgets + +## 12. When to Extend Design System + +### Add New Color +**When:** New brand color approved by design team + +**Process:** +1. Add to `ui_colors.dart`: +```dart +class UiColors { + static const myNewColor = Color(0xFF123456); +} +``` +2. Update theme if needed +3. Use in features + +### Add New Typography Style +**When:** New text style pattern emerges across multiple features + +**Process:** +1. Add to `ui_typography.dart`: +```dart +class UiTypography { + static const myNewStyle = TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + fontFamily: _fontFamily, + ); +} +``` +2. Use in features + +### Add Shared Widget +**When:** Widget reused in 3+ features + +**Process:** +1. Create in `lib/src/widgets/`: +```dart +// my_widget.dart +class MyWidget extends StatelessWidget { + // Implementation using design system tokens +} +``` +2. Export from `design_system.dart` +3. Use across features + +## Summary + +**Core Rules:** +1. **All colors from `UiColors`** - Zero hex codes in features +2. **All typography from `UiTypography`** - Zero custom TextStyle +3. **All spacing/radius/elevation from `UiConstants`** - Zero magic numbers +4. **All icons from `UiIcons`** - Zero direct library imports +5. **Theme defined once** in app entry point +6. **POC → Production** requires design system integration step + +**The Golden Rule:** Design system is immutable. Features adapt to the system, not the other way around. + +When implementing UI: +1. Import `package:design_system/design_system.dart` +2. Use design system tokens exclusively +3. Match POC intent with available tokens +4. Request new tokens only when truly necessary +5. Never create temporary hardcoded workarounds + +Visual consistency is non-negotiable. Every pixel must come from the design system. diff --git a/.agents/skills/krow-mobile-development-rules/SKILL.md b/.agents/skills/krow-mobile-development-rules/SKILL.md new file mode 100644 index 00000000..4f4adc0f --- /dev/null +++ b/.agents/skills/krow-mobile-development-rules/SKILL.md @@ -0,0 +1,646 @@ +--- +name: krow-mobile-development-rules +description: Enforce KROW mobile app development standards including file structure, naming conventions, logic placement boundaries, localization, Data Connect integration, and prototype migration rules. Use this skill whenever working on KROW Flutter mobile features, creating new packages, implementing BLoCs, integrating with backend, or migrating from prototypes. Critical for maintaining clean architecture and preventing architectural degradation. +--- + +# KROW Mobile Development Rules + +These rules are **NON-NEGOTIABLE** enforcement guidelines for the KROW mobile application. They prevent architectural degradation and ensure consistency across the codebase. + +## When to Use This Skill + +- Creating new mobile features or packages +- Implementing BLoCs, Use Cases, or Repositories +- Integrating with Firebase Data Connect backend +- Migrating code from prototypes +- Reviewing mobile code for compliance +- Setting up new feature modules +- Handling user sessions and authentication +- Implementing navigation flows + +## 1. File Creation & Package Structure + +### Feature-First Packaging + +**✅ DO:** +- Create new features as independent packages: + ``` + apps/mobile/packages/features/// + ├── lib/ + │ ├── src/ + │ │ ├── domain/ + │ │ │ ├── repositories/ + │ │ │ └── usecases/ + │ │ ├── data/ + │ │ │ └── repositories_impl/ + │ │ └── presentation/ + │ │ ├── blocs/ + │ │ ├── pages/ + │ │ └── widgets/ + │ └── .dart # Barrel file + └── pubspec.yaml + ``` + +**❌ DON'T:** +- Add features to `apps/mobile/packages/core` directly +- Create files in app directories (`apps/mobile/apps/client/` or `apps/mobile/apps/staff/`) +- Create cross-feature or cross-app dependencies (features must not import other features) + +### Path Conventions (Strict) + +Follow these exact paths: + +| Layer | Path Pattern | Example | +|-------|-------------|---------| +| **Entities** | `apps/mobile/packages/domain/lib/src/entities/.dart` | `user.dart`, `shift.dart` | +| **Repository Interface** | `.../features///lib/src/domain/repositories/_repository_interface.dart` | `auth_repository_interface.dart` | +| **Repository Impl** | `.../features///lib/src/data/repositories_impl/_repository_impl.dart` | `auth_repository_impl.dart` | +| **Use Cases** | `.../features///lib/src/application/_usecase.dart` | `login_usecase.dart` | +| **BLoCs** | `.../features///lib/src/presentation/blocs/_bloc.dart` | `auth_bloc.dart` | +| **Pages** | `.../features///lib/src/presentation/pages/_page.dart` | `login_page.dart` | +| **Widgets** | `.../features///lib/src/presentation/widgets/_widget.dart` | `password_field.dart` | + +### Barrel Files + +**✅ DO:** +```dart +// lib/auth_feature.dart +export 'src/presentation/pages/login_page.dart'; +export 'src/domain/repositories/auth_repository_interface.dart'; +// Only export PUBLIC API +``` + +**❌ DON'T:** +```dart +// Don't export internal implementation details +export 'src/data/repositories_impl/auth_repository_impl.dart'; +export 'src/presentation/blocs/auth_bloc.dart'; +``` + +## 2. Naming Conventions (Dart Standard) + +| Type | Convention | Example | File Name | +|------|-----------|---------|-----------| +| **Files** | `snake_case` | `user_profile_page.dart` | - | +| **Classes** | `PascalCase` | `UserProfilePage` | - | +| **Variables** | `camelCase` | `userProfile` | - | +| **Interfaces** | End with `Interface` | `AuthRepositoryInterface` | `auth_repository_interface.dart` | +| **Implementations** | End with `Impl` | `AuthRepositoryImpl` | `auth_repository_impl.dart` | +| **BLoCs** | End with `Bloc` or `Cubit` | `AuthBloc`, `ProfileCubit` | `auth_bloc.dart` | +| **Use Cases** | End with `UseCase` | `LoginUseCase` | `login_usecase.dart` | + +## 3. Logic Placement (Zero Tolerance Boundaries) + +### Business Rules → Use Cases ONLY + +**✅ CORRECT:** +```dart +// login_usecase.dart +class LoginUseCase extends UseCase { + @override + Future> call(LoginParams params) async { + // Business logic here: validation, transformation, orchestration + if (params.email.isEmpty) { + return Left(ValidationFailure('Email required')); + } + return await repository.login(params); + } +} +``` + +**❌ FORBIDDEN:** +```dart +// ❌ Business logic in BLoC +class AuthBloc extends Bloc { + on((event, emit) { + if (event.email.isEmpty) { // ← NO! This is business logic + emit(AuthError('Email required')); + } + }); +} + +// ❌ Business logic in Widget +class LoginPage extends StatelessWidget { + void _login() { + if (_emailController.text.isEmpty) { // ← NO! This is business logic + showSnackbar('Email required'); + } + } +} +``` + +### State Logic → BLoCs ONLY + +**✅ CORRECT:** +```dart +// auth_bloc.dart +class AuthBloc extends Bloc { + on((event, emit) async { + emit(AuthLoading()); + final result = await loginUseCase(LoginParams(email: event.email)); + result.fold( + (failure) => emit(AuthError(failure)), + (user) => emit(AuthAuthenticated(user)), + ); + }); +} + +// login_page.dart (StatelessWidget) +class LoginPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is AuthLoading) return LoadingIndicator(); + if (state is AuthError) return ErrorWidget(state.message); + return LoginForm(); + }, + ); + } +} +``` + +**❌ FORBIDDEN:** +```dart +// ❌ setState in Pages for complex state +class LoginPage extends StatefulWidget { + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + bool _isLoading = false; // ← NO! Use BLoC + String? _error; // ← NO! Use BLoC + + void _login() { + setState(() => _isLoading = true); // ← NO! Use BLoC + } +} +``` + +**RECOMMENDATION:** Pages should be `StatelessWidget` with state delegated to BLoCs. + +### Data Transformation → Repositories + +**✅ CORRECT:** +```dart +// profile_repository_impl.dart +class ProfileRepositoryImpl implements ProfileRepositoryInterface { + @override + Future getProfile(String id) async { + final response = await dataConnect.getStaffById(id: id).execute(); + // Data transformation happens here + return Staff( + id: response.data.staff.id, + name: response.data.staff.name, + // Map Data Connect model to Domain entity + ); + } +} +``` + +**❌ FORBIDDEN:** +```dart +// ❌ JSON parsing in UI +class ProfilePage extends StatelessWidget { + @override + Widget build(BuildContext context) { + final json = jsonDecode(response.body); // ← NO! + final name = json['name']; + } +} + +// ❌ JSON parsing in Domain Use Case +class GetProfileUseCase extends UseCase { + @override + Future> call(String id) async { + final response = await http.get('/staff/$id'); + final json = jsonDecode(response.body); // ← NO! + } +} +``` + +### Navigation → Flutter Modular + Safe Extensions + +**✅ CORRECT:** +```dart +// Use Safe Navigation Extensions +import 'package:krow_core/krow_core.dart'; + +// In widget/BLoC: +Modular.to.safePush('/profile'); +Modular.to.safeNavigate('/home'); +Modular.to.popSafe(); + +// Even better: Use Typed Navigators +Modular.to.toStaffHome(); // Defined in StaffNavigator +Modular.to.toShiftDetails(shiftId: '123'); +``` + +**❌ FORBIDDEN:** +```dart +// ❌ Direct Navigator.push +Navigator.push( + context, + MaterialPageRoute(builder: (_) => ProfilePage()), +); + +// ❌ Direct Modular navigation without safety +Modular.to.navigate('/profile'); // ← Can cause blank screens +Modular.to.pop(); // ← Can crash if stack is empty +``` + +**PATTERN:** All navigation MUST have fallback to Home page. Safe extensions automatically handle this. + +### Session Management → DataConnectService + SessionHandlerMixin + +**✅ CORRECT:** +```dart +// In main.dart: +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Initialize session listener (pick allowed roles for app) + DataConnectService.instance.initializeAuthListener( + allowedRoles: ['STAFF', 'BOTH'], // for staff app + ); + + runApp( + SessionListener( // Wraps entire app + child: ModularApp(module: AppModule(), child: AppWidget()), + ), + ); +} + +// In repository: +class ProfileRepositoryImpl implements ProfileRepositoryInterface { + final DataConnectService _service = DataConnectService.instance; + + @override + Future getProfile(String id) async { + // _service.run() handles: + // - Auth validation + // - Token refresh (if <5 min to expiry) + // - Error handling with 3 retries + return await _service.run(() async { + final response = await _service.connector + .getStaffById(id: id) + .execute(); + return _mapToStaff(response.data.staff); + }); + } +} +``` + +**PATTERN:** +- **SessionListener** widget wraps app and shows dialogs for session errors +- **SessionHandlerMixin** in `DataConnectService` provides automatic token refresh +- **3-attempt retry logic** with exponential backoff (1s → 2s → 4s) +- **Role validation** configurable per app + +## 4. Localization Integration (core_localization) + +All user-facing text MUST be localized. + +### String Management + +**✅ CORRECT:** +```dart +// In presentation layer: +import 'package:core_localization/core_localization.dart'; + +class LoginPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Text(context.strings.loginButton); // ← From localization + return ElevatedButton( + onPressed: _login, + child: Text(context.strings.submit), + ); + } +} +``` + +**❌ FORBIDDEN:** +```dart +// ❌ Hardcoded English strings +Text('Login') +Text('Submit') +ElevatedButton(child: Text('Click here')) +``` + +### BLoC Integration + +**✅ CORRECT:** +```dart +// BLoCs emit domain failures (not localized strings) +class AuthBloc extends Bloc { + on((event, emit) async { + final result = await loginUseCase(params); + result.fold( + (failure) => emit(AuthError(failure)), // ← Domain failure + (user) => emit(AuthAuthenticated(user)), + ); + }); +} + +// UI translates failures to user-friendly messages +class LoginPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is AuthError) { + final message = ErrorTranslator.translate( + state.failure, + context.strings, + ); + return ErrorWidget(message); // ← Localized + } + }, + ); + } +} +``` + +### App Setup + +Apps must import `LocalizationModule()`: +```dart +// app_module.dart +class AppModule extends Module { + @override + List get imports => [ + LocalizationModule(), // ← Required + DataConnectModule(), + ]; +} + +// main.dart +runApp( + BlocProvider( // ← Expose locale state + create: (_) => Modular.get(), + child: TranslationProvider( // ← Enable context.strings + child: MaterialApp.router(...), + ), + ), +); +``` + +## 5. Data Connect Integration + +All backend access goes through `DataConnectService`. + +### Repository Pattern + +**Step 1:** Define interface in feature domain: +```dart +// domain/repositories/profile_repository_interface.dart +abstract interface class ProfileRepositoryInterface { + Future getProfile(String id); + Future updateProfile(Staff profile); +} +``` + +**Step 2:** Implement using `DataConnectService.run()`: +```dart +// data/repositories_impl/profile_repository_impl.dart +class ProfileRepositoryImpl implements ProfileRepositoryInterface { + final DataConnectService _service = DataConnectService.instance; + + @override + Future getProfile(String id) async { + return await _service.run(() async { + final response = await _service.connector + .getStaffById(id: id) + .execute(); + return _mapToStaff(response.data.staff); + }); + } +} +``` + +**Benefits of `_service.run()`:** +- ✅ Automatic auth validation +- ✅ Token refresh if needed +- ✅ 3-attempt retry with exponential backoff +- ✅ Consistent error handling + +### Session Store Pattern + +After successful auth, populate session stores: +```dart +// For Staff App: +StaffSessionStore.instance.setSession( + StaffSession( + user: user, + staff: staff, + ownerId: ownerId, + ), +); + +// For Client App: +ClientSessionStore.instance.setSession( + ClientSession( + user: user, + business: business, + ), +); +``` + +**Lazy Loading:** If session is null, fetch via `getStaffById()` or `getBusinessById()` and update store. + +## 6. Prototype Migration Rules + +When migrating from `prototypes/`: + +### ✅ MAY Copy +- Icons, images, assets (but match to design system) +- `build` methods for UI layout structure +- Screen flow and navigation patterns + +### ❌ MUST REJECT & REFACTOR +- `GetX`, `Provider`, or `MVC` patterns +- Any state management not using BLoC +- Direct HTTP calls (must use Data Connect) +- Hardcoded colors/typography (must use design system) +- Global state variables +- Navigation without Modular + +### Colors & Typography Migration +**When matching POC to production:** +1. Find closest color in `UiColors` (don't add new colors without approval) +2. Find closest text style in `UiTypography` +3. Use design system constants, NOT POC hardcoded values + +**DO NOT change the design system itself.** Colors and typography are FINAL. Match your feature to the system, not the other way around. + +## 7. Handling Ambiguity + +If requirements are unclear: + +1. **STOP** - Don't guess domain fields or workflows +2. **ANALYZE** - Refer to: + - Architecture: `apps/mobile/docs/01-architecture-principles.md` + - Design System: `apps/mobile/docs/02-design-system-usage.md` + - Existing features for patterns +3. **DOCUMENT** - Add `// ASSUMPTION: ` if you must proceed +4. **ASK** - Prefer asking user for clarification on business rules + +## 8. Dependencies + +### DO NOT +- Add 3rd party packages without checking `apps/mobile/packages/core` first +- Add `firebase_auth` or `firebase_data_connect` to Feature packages (they belong in `data_connect` only) +- Use `addSingleton` for BLoCs (always use `add` method in Modular) + +### DO +- Use `DataConnectService.instance` for backend operations +- Use Flutter Modular for dependency injection +- Register BLoCs with `i.addSingleton(() => CubitType(...))` +- Register Use Cases as factories or singletons as needed + +## 9. Error Handling Pattern + +### Domain Failures +```dart +// domain/failures/auth_failure.dart +abstract class AuthFailure extends Failure { + const AuthFailure(String message) : super(message); +} + +class InvalidCredentialsFailure extends AuthFailure { + const InvalidCredentialsFailure() : super('Invalid credentials'); +} +``` + +### Repository Error Mapping +```dart +// Map Data Connect exceptions to Domain failures +try { + final response = await dataConnect.query(); + return Right(response); +} on DataConnectException catch (e) { + if (e.message.contains('unauthorized')) { + return Left(InvalidCredentialsFailure()); + } + return Left(ServerFailure(e.message)); +} +``` + +### UI Feedback +```dart +// BLoC emits error state +emit(AuthError(failure)); + +// UI shows user-friendly message +if (state is AuthError) { + final message = ErrorTranslator.translate(state.failure, context.strings); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); +} +``` + +### Session Errors +`SessionListener` automatically shows dialogs for: +- Session expiration +- Token refresh failures +- Network errors during auth + +## 10. Testing Requirements + +### Unit Tests +```dart +// Test use cases with real repository implementations +test('login with valid credentials returns user', () async { + final useCase = LoginUseCase(repository: mockRepository); + final result = await useCase(LoginParams(email: 'test@test.com')); + expect(result.isRight(), true); +}); +``` + +### Widget Tests +```dart +// Test UI widgets and BLoC interactions +testWidgets('shows loading indicator when logging in', (tester) async { + await tester.pumpWidget( + BlocProvider( + create: (_) => authBloc, + child: LoginPage(), + ), + ); + + authBloc.add(LoginRequested(email: 'test@test.com')); + await tester.pump(); + + expect(find.byType(LoadingIndicator), findsOneWidget); +}); +``` + +### Integration Tests +- Test full feature flows end-to-end with Data Connect +- Use dependency injection to swap implementations if needed + +## 11. Clean Code Principles + +### Documentation +- ✅ Add human readable doc comments for `dartdoc` for all classes and methods. +```dart +/// Authenticates user with email and password. +/// +/// Returns [User] on success or [AuthFailure] on failure. +/// Throws [NetworkException] if connection fails. +class LoginUseCase extends UseCase { + // ... +} +``` + +### Single Responsibility +- Keep methods focused on one task +- Extract complex logic to separate methods +- Keep widget build methods concise +- Extract complex widgets to separate files + +### Meaningful Names +```dart +// ✅ GOOD +final isProfileComplete = await checkProfileCompletion(); +final userShifts = await fetchUserShifts(); + +// ❌ BAD +final flag = await check(); +final data = await fetch(); +``` + +## Enforcement Checklist + +Before merging any mobile feature code: + +### Architecture Compliance +- [ ] Feature follows package structure (domain/data/presentation) +- [ ] No business logic in BLoCs or Widgets +- [ ] All state management via BLoCs +- [ ] All backend access via repositories +- [ ] Session accessed via SessionStore, not global state +- [ ] Navigation uses Flutter Modular safe extensions +- [ ] No feature-to-feature imports + +### Code Quality +- [ ] No hardcoded strings (use localization) +- [ ] No hardcoded colors/typography (use design system) +- [ ] All spacing uses UiConstants +- [ ] Doc comments on public APIs +- [ ] Meaningful variable names +- [ ] Zero analyzer warnings + +### Integration +- [ ] Data Connect queries via `_service.run()` +- [ ] Error handling with domain failures +- [ ] Proper dependency injection in modules + +## Summary + +The key principle: **Clean Architecture with zero tolerance for violations.** Business logic in Use Cases, state in BLoCs, data access in Repositories, UI in Widgets. Features are isolated, backend is centralized, localization is mandatory, and design system is immutable. + +When in doubt, refer to existing features following these patterns or ask for clarification. It's better to ask than to introduce architectural debt. diff --git a/.agents/skills/krow-mobile-release/SKILL.md b/.agents/skills/krow-mobile-release/SKILL.md new file mode 100644 index 00000000..78e2b38f --- /dev/null +++ b/.agents/skills/krow-mobile-release/SKILL.md @@ -0,0 +1,778 @@ +--- +name: krow-mobile-release +description: KROW mobile app release process including versioning strategy, CHANGELOG management, GitHub Actions workflows, APK signing, Git tagging, and hotfix procedures. Use this when preparing mobile releases, updating CHANGELOGs, triggering release workflows, creating hotfix branches, troubleshooting release issues, or documenting release features. Covers both staff (worker) and client mobile products across dev/stage/prod environments. +--- + +# KROW Mobile Release Process + +This skill defines the comprehensive release process for KROW mobile applications (staff and client). It covers versioning, changelog management, GitHub Actions automation, and hotfix procedures. + +## When to Use This Skill + +- Preparing for a mobile app release +- Updating CHANGELOG files with new features +- Triggering GitHub Actions release workflows +- Creating hotfix branches for production issues +- Understanding version numbering strategy +- Setting up APK signing secrets +- Troubleshooting release workflow failures +- Documenting release notes +- Managing release cadence (dev → stage → prod) + +## Quick Reference + +### Release Workflows +- **Product Release:** [GitHub Actions - Product Release](https://github.com/Oloodi/krow-workforce/actions/workflows/product-release.yml) +- **Hotfix Creation:** [GitHub Actions - Product Hotfix](https://github.com/Oloodi/krow-workforce/actions/workflows/hotfix-branch-creation.yml) + +### Key Files +- **Staff CHANGELOG:** `apps/mobile/apps/staff/CHANGELOG.md` +- **Client CHANGELOG:** `apps/mobile/apps/client/CHANGELOG.md` +- **Staff Version:** `apps/mobile/apps/staff/pubspec.yaml` +- **Client Version:** `apps/mobile/apps/client/pubspec.yaml` + +### Comprehensive Documentation +For complete details, see: [`docs/RELEASE/mobile-releases.md`](docs/RELEASE/mobile-releases.md) (900+ lines) + +## 1. Versioning Strategy + +### Format + +``` +v{major}.{minor}.{patch}-{milestone} +``` + +**Examples:** +- `v0.0.1-m4` - Milestone 4 release +- `v0.1.0-m5` - Minor version bump for Milestone 5 +- `v1.0.0` - First production release (no milestone suffix) + +### Semantic Versioning Rules + +**Major (X.0.0):** +- Breaking changes +- Complete architecture overhaul +- Incompatible API changes + +**Minor (0.X.0):** +- New features +- Backwards-compatible additions +- Milestone completions + +**Patch (0.0.X):** +- Bug fixes +- Security patches +- Performance improvements + +**Milestone Suffix:** +- `-m1`, `-m2`, `-m3`, `-m4`, etc. +- Indicates pre-production milestone phase +- Removed for production releases + +### Version Location + +Versions are defined in `pubspec.yaml`: + +**Staff App:** +```yaml +# apps/mobile/apps/staff/pubspec.yaml +name: krow_staff_app +version: 0.0.1-m4+1 # version+build_number +``` + +**Client App:** +```yaml +# apps/mobile/apps/client/pubspec.yaml +name: krow_client_app +version: 0.0.1-m4+1 +``` + +**Format:** `version+build` +- `version`: Semantic version with milestone (e.g., `0.0.1-m4`) +- `build`: Build number (increments with each build, e.g., `+1`, `+2`) + +## 2. CHANGELOG Management + +### Format + +Each app maintains a separate CHANGELOG following [Keep a Changelog](https://keepachangelog.com/) format. + +**Structure:** +```markdown +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +### Added +- New feature descriptions + +### Changed +- Modified feature descriptions + +### Fixed +- Bug fix descriptions + +### Removed +- Removed feature descriptions + +## [0.0.1-m4] - Milestone 4 - 2026-03-05 + +### Added +- Profile management with 13 subsections +- Documents & certificates management +- Benefits overview section +- Camera/gallery support for attire verification + +### Changed +- Enhanced session management with auto token refresh + +### Fixed +- Navigation fallback to home on invalid routes +``` + +### Section Guidelines + +**[Unreleased]** +- Work in progress +- Features merged to dev but not released +- Updated continuously during development + +**[Version] - Milestone X - Date** +- Released version +- Format: `[X.Y.Z-mN] - Milestone N - YYYY-MM-DD` +- Organized by change type (Added/Changed/Fixed/Removed) + +### Change Type Definitions + +**Added:** +- New features +- New UI screens +- New API integrations +- New user-facing capabilities + +**Changed:** +- Modifications to existing features +- UI/UX improvements +- Performance enhancements +- Refactored code (if user-facing impact) + +**Fixed:** +- Bug fixes +- Error handling improvements +- Crash fixes +- UI/UX issues resolved + +**Removed:** +- Deprecated features +- Removed screens or capabilities +- Discontinued integrations + +### Writing Guidelines + +**✅ GOOD:** +```markdown +### Added +- Profile management with 13 subsections organized into onboarding, compliance, finances, and support categories +- Documents & certificates management with upload, status tracking, and expiry dates +- Camera and gallery support for attire verification with photo capture +- Benefits overview section displaying perks and company information +``` + +**❌ BAD:** +```markdown +### Added +- New stuff +- Fixed things +- Updated code +``` + +**Key Principles:** +- Be specific and descriptive +- Focus on user-facing changes +- Mention UI screens, features, or capabilities +- Avoid technical jargon users won't understand +- Group related changes together + +### Updating CHANGELOG Workflow + +**Step 1:** During development, add to `[Unreleased]`: +```markdown +## [Unreleased] + +### Added +- New shift calendar view with month/week toggle +- Shift acceptance confirmation dialog + +### Fixed +- Navigation crash when popping empty stack +``` + +**Step 2:** Before release, move to version section: +```markdown +## [0.1.0-m5] - Milestone 5 - 2026-03-15 + +### Added +- New shift calendar view with month/week toggle +- Shift acceptance confirmation dialog + +### Fixed +- Navigation crash when popping empty stack + +## [Unreleased] + +``` + +**Step 3:** Update version in `pubspec.yaml`: +```yaml +version: 0.1.0-m5+1 +``` + +## 3. Git Tagging Strategy + +### Tag Format + +``` +krow-withus--mobile/-vX.Y.Z +``` + +**Components:** +- ``: `worker` (staff) or `client` +- ``: `dev`, `stage`, or `prod` +- `vX.Y.Z`: Semantic version (from pubspec.yaml) + +**Examples:** +``` +krow-withus-worker-mobile/dev-v0.0.1-m4 +krow-withus-worker-mobile/stage-v0.0.1-m4 +krow-withus-worker-mobile/prod-v0.0.1-m4 +krow-withus-client-mobile/dev-v0.0.1-m4 +``` + +### Tag Creation + +Tags are created automatically by GitHub Actions workflows. Manual tagging: + +```bash +# Staff app - dev environment +git tag krow-withus-worker-mobile/dev-v0.0.1-m4 +git push origin krow-withus-worker-mobile/dev-v0.0.1-m4 + +# Client app - prod environment +git tag krow-withus-client-mobile/prod-v1.0.0 +git push origin krow-withus-client-mobile/prod-v1.0.0 +``` + +### Tag Listing + +```bash +# List all mobile tags +git tag -l "krow-withus-*-mobile/*" + +# List staff app tags +git tag -l "krow-withus-worker-mobile/*" + +# List production tags +git tag -l "krow-withus-*-mobile/prod-*" +``` + +## 4. GitHub Actions Workflows + +### 4.1 Product Release Workflow + +**File:** `.github/workflows/product-release.yml` + +**Purpose:** Automated production releases with APK signing + +**Trigger:** Manual dispatch via GitHub UI + +**Inputs:** +- `app`: Select `worker` (staff) or `client` +- `environment`: Select `dev`, `stage`, or `prod` + +**Process:** +1. ✅ Extracts version from `pubspec.yaml` automatically +2. ✅ Builds signed APKs for selected app +3. ✅ Creates GitHub release with CHANGELOG notes +4. ✅ Tags release (e.g., `krow-withus-worker-mobile/dev-v0.0.1-m4`) +5. ✅ Uploads APKs as release assets +6. ✅ Generates step summary with emojis + +**Key Features:** +- **No manual version input** - reads from pubspec.yaml +- **APK signing** - uses GitHub Secrets for keystore +- **CHANGELOG extraction** - pulls release notes automatically +- **Visual feedback** - emojis in all steps + +**Usage:** +``` +1. Go to: GitHub Actions → "📦 Product Release" +2. Click "Run workflow" +3. Select app (worker/client) +4. Select environment (dev/stage/prod) +5. Click "Run workflow" +6. Wait for completion (~5-10 minutes) +``` + +**Release Naming:** +``` +Krow With Us - Worker Product - DEV - v0.0.1-m4 +Krow With Us - Client Product - PROD - v1.0.0 +``` + +### 4.2 Product Hotfix Workflow + +**File:** `.github/workflows/hotfix-branch-creation.yml` + +**Purpose:** Emergency production fix automation + +**Trigger:** Manual dispatch with version input + +**Inputs:** +- `current_version`: Current production version (e.g., `0.0.1-m4`) +- `issue_description`: Brief description of the hotfix + +**Process:** +1. ✅ Creates `hotfix/` branch from latest production tag +2. ✅ Auto-increments PATCH version (e.g., `0.0.1-m4` → `0.0.2-m4`) +3. ✅ Updates `pubspec.yaml` with new version +4. ✅ Updates `CHANGELOG.md` with hotfix section +5. ✅ Creates PR back to main branch +6. ✅ Includes hotfix instructions in PR description + +**Usage:** +``` +1. Go to: GitHub Actions → "🚨 Product Hotfix - Create Branch" +2. Click "Run workflow" +3. Enter current production version (e.g., 0.0.1-m4) +4. Enter issue description (e.g., "critical crash on login") +5. Click "Run workflow" +6. Workflow creates branch and PR +7. Fix bug on hotfix branch +8. Merge PR to main +9. Use Product Release workflow to deploy +``` + +**Hotfix Branch Naming:** +``` +hotfix/0.0.2-m4-critical-crash-on-login +``` + +### 4.3 Helper Scripts + +**Location:** `.github/scripts/` + +**Available Scripts:** +1. **extract-version.sh** - Extract version from pubspec.yaml +2. **generate-tag-name.sh** - Generate standardized tag names +3. **extract-release-notes.sh** - Extract CHANGELOG sections +4. **create-release-summary.sh** - Generate GitHub Step Summary with emojis + +**Script Permissions:** +```bash +chmod +x .github/scripts/*.sh +``` + +**Usage Example:** +```bash +# Extract version from staff app +.github/scripts/extract-version.sh apps/mobile/apps/staff/pubspec.yaml + +# Generate tag name +.github/scripts/generate-tag-name.sh worker dev 0.0.1-m4 + +# Extract release notes for version +.github/scripts/extract-release-notes.sh apps/mobile/apps/staff/CHANGELOG.md 0.0.1-m4 +``` + +## 5. APK Signing Setup + +### Required GitHub Secrets (24 Total) + +**Per App (12 secrets each):** + +**Staff (Worker) App:** +``` +STAFF_UPLOAD_KEYSTORE_BASE64 # Base64-encoded keystore file +STAFF_UPLOAD_STORE_PASSWORD # Keystore password +STAFF_UPLOAD_KEY_ALIAS # Key alias +STAFF_UPLOAD_KEY_PASSWORD # Key password +STAFF_KEYSTORE_PROPERTIES_BASE64 # Base64-encoded key.properties file +``` + +**Client App:** +``` +CLIENT_UPLOAD_KEYSTORE_BASE64 +CLIENT_UPLOAD_STORE_PASSWORD +CLIENT_UPLOAD_KEY_ALIAS +CLIENT_UPLOAD_KEY_PASSWORD +CLIENT_KEYSTORE_PROPERTIES_BASE64 +``` + +### Generating Secrets + +**Step 1: Create Keystore** + +```bash +# For staff app +keytool -genkey -v \ + -keystore staff-upload-keystore.jks \ + -keyalg RSA \ + -keysize 2048 \ + -validity 10000 \ + -alias staff-upload + +# For client app +keytool -genkey -v \ + -keystore client-upload-keystore.jks \ + -keyalg RSA \ + -keysize 2048 \ + -validity 10000 \ + -alias client-upload +``` + +**Step 2: Base64 Encode** + +```bash +# Encode keystore +base64 -i staff-upload-keystore.jks | tr -d '\n' > staff-keystore.txt + +# Encode key.properties +base64 -i key.properties | tr -d '\n' > key-props.txt +``` + +**Step 3: Add to GitHub Secrets** + +``` +Repository → Settings → Secrets and variables → Actions → New repository secret +``` + +Add each secret: +- Name: `STAFF_UPLOAD_KEYSTORE_BASE64` +- Value: Contents of `staff-keystore.txt` + +Repeat for all 24 secrets. + +### key.properties Format + +```properties +storePassword=your_store_password +keyPassword=your_key_password +keyAlias=staff-upload +storeFile=../staff-upload-keystore.jks +``` + +## 6. Release Process (Step-by-Step) + +### Standard Release (Dev/Stage/Prod) + +**Step 1: Prepare CHANGELOG** + +Update `CHANGELOG.md` with all changes since last release: +```markdown +## [0.1.0-m5] - Milestone 5 - 2026-03-15 + +### Added +- Shift calendar with month/week views +- Enhanced navigation with typed routes +- Profile completion wizard + +### Fixed +- Session token refresh timing +- Navigation fallback logic +``` + +**Step 2: Update Version** + +Edit `pubspec.yaml`: +```yaml +version: 0.1.0-m5+1 # Changed from 0.0.1-m4+1 +``` + +**Step 3: Commit and Push** + +```bash +git add apps/mobile/apps/staff/CHANGELOG.md +git add apps/mobile/apps/staff/pubspec.yaml +git commit -m "chore(staff): prepare v0.1.0-m5 release" +git push origin dev +``` + +**Step 4: Trigger Workflow** + +1. Go to GitHub Actions → "📦 Product Release" +2. Click "Run workflow" +3. Select branch: `dev` +4. Select app: `worker` (or `client`) +5. Select environment: `dev` (or `stage`, `prod`) +6. Click "Run workflow" + +**Step 5: Monitor Progress** + +Watch workflow execution: +- ⏳ Version extraction +- ⏳ APK building +- ⏳ APK signing +- ⏳ GitHub Release creation +- ⏳ Tag creation +- ⏳ Asset upload + +**Step 6: Verify Release** + +1. Check GitHub Releases page +2. Download APK to verify +3. Install on test device +4. Verify version in app + +### Hotfix Release + +**Step 1: Identify Production Issue** + +- Critical bug in production +- User-reported crash +- Security vulnerability + +**Step 2: Trigger Hotfix Workflow** + +1. Go to GitHub Actions → "🚨 Product Hotfix - Create Branch" +2. Click "Run workflow" +3. Enter current version: `0.0.1-m4` +4. Enter description: `Critical crash on login screen` +5. Click "Run workflow" + +**Step 3: Review Created Branch** + +Workflow creates: +- Branch: `hotfix/0.0.2-m4-critical-crash-on-login` +- PR to `main` branch +- Updated `pubspec.yaml`: `0.0.2-m4+1` +- Updated `CHANGELOG.md` with hotfix section + +**Step 4: Fix Bug** + +```bash +git checkout hotfix/0.0.2-m4-critical-crash-on-login + +# Make fixes +# ... code changes ... + +git add . +git commit -m "fix(auth): resolve crash on login screen" +git push origin hotfix/0.0.2-m4-critical-crash-on-login +``` + +**Step 5: Merge PR** + +1. Review PR on GitHub +2. Approve and merge to `main` +3. Delete hotfix branch + +**Step 6: Release to Production** + +1. Use Product Release workflow +2. Select `main` branch +3. Select `prod` environment +4. Deploy hotfix + +## 7. Release Cadence + +### Development (dev) + +- **Frequency:** Multiple times per day +- **Purpose:** Testing features in dev environment +- **Branch:** `dev` +- **Audience:** Internal development team +- **Approval:** Not required + +### Staging (stage) + +- **Frequency:** 1-2 times per week +- **Purpose:** QA testing, stakeholder demos +- **Branch:** `main` +- **Audience:** QA team, stakeholders +- **Approval:** Tech lead approval + +### Production (prod) + +- **Frequency:** Every 2-3 weeks (milestone completion) +- **Purpose:** End-user releases +- **Branch:** `main` +- **Audience:** All users +- **Approval:** Product owner + tech lead approval + +### Milestone Releases + +- **Frequency:** Every 2-4 weeks +- **Version Bump:** Minor version (e.g., `0.1.0-m5` → `0.2.0-m6`) +- **Process:** + 1. Complete all milestone features + 2. Update CHANGELOG with comprehensive release notes + 3. Deploy to stage for final QA + 4. After approval, deploy to prod + 5. Create GitHub release with milestone summary + +## 8. Troubleshooting + +### Workflow Fails: Version Extraction + +**Error:** "Could not extract version from pubspec.yaml" + +**Solutions:** +1. Verify `pubspec.yaml` exists at expected path +2. Check version format: `version: X.Y.Z-mN+B` +3. Ensure no extra spaces or tabs +4. Verify file is committed and pushed + +### Workflow Fails: APK Signing + +**Error:** "Keystore password incorrect" + +**Solutions:** +1. Verify GitHub Secrets are set correctly +2. Re-generate and re-encode keystore +3. Check key.properties format +4. Ensure passwords don't contain special characters that need escaping + +### Workflow Fails: CHANGELOG Extraction + +**Error:** "Could not find version in CHANGELOG" + +**Solutions:** +1. Verify CHANGELOG format matches: `## [X.Y.Z-mN] - Milestone N - YYYY-MM-DD` +2. Check square brackets are present +3. Ensure version matches pubspec.yaml +4. Add version section if missing + +### Tag Already Exists + +**Error:** "tag already exists" + +**Solutions:** +1. Delete existing tag locally and remotely: +```bash +git tag -d krow-withus-worker-mobile/dev-v0.0.1-m4 +git push origin :refs/tags/krow-withus-worker-mobile/dev-v0.0.1-m4 +``` +2. Re-run workflow + +### Build Fails: Flutter Errors + +**Error:** "flutter build failed" + +**Solutions:** +1. Test build locally first: +```bash +cd apps/mobile/apps/staff +flutter build apk --release +``` +2. Fix any analyzer errors +3. Ensure all dependencies are compatible +4. Clear build cache: +```bash +flutter clean +flutter pub get +``` + +## 9. Local Testing + +Before triggering workflows, test builds locally: + +### Building APKs Locally + +**Staff App:** +```bash +cd apps/mobile/apps/staff +flutter clean +flutter pub get +flutter build apk --release +``` + +**Client App:** +```bash +cd apps/mobile/apps/client +flutter clean +flutter pub get +flutter build apk --release +``` + +### Testing Release Notes + +Extract CHANGELOG section: +```bash +.github/scripts/extract-release-notes.sh \ + apps/mobile/apps/staff/CHANGELOG.md \ + 0.0.1-m4 +``` + +### Verifying Version + +Extract version from pubspec: +```bash +.github/scripts/extract-version.sh \ + apps/mobile/apps/staff/pubspec.yaml +``` + +## 10. Best Practices + +### CHANGELOG +- ✅ Update continuously during development +- ✅ Be specific and user-focused +- ✅ Group related changes +- ✅ Include UI/UX changes +- ❌ Don't include technical debt or refactoring (unless user-facing) +- ❌ Don't use vague descriptions + +### Versioning +- ✅ Use semantic versioning strictly +- ✅ Increment patch for bug fixes +- ✅ Increment minor for new features +- ✅ Keep milestone suffix until production +- ❌ Don't skip versions +- ❌ Don't use arbitrary version numbers + +### Git Tags +- ✅ Follow standard format +- ✅ Let workflow create tags automatically +- ✅ Keep tags synced with releases +- ❌ Don't create tags manually unless necessary +- ❌ Don't reuse deleted tags + +### Workflows +- ✅ Test builds locally first +- ✅ Monitor workflow execution +- ✅ Verify release assets +- ✅ Test APK on device before announcing +- ❌ Don't trigger multiple workflows simultaneously +- ❌ Don't bypass approval process + +## Summary + +**Release Process Overview:** +1. Update CHANGELOG with changes +2. Update version in pubspec.yaml +3. Commit and push to appropriate branch +4. Trigger Product Release workflow +5. Monitor execution and verify release +6. Test APK on device +7. Announce to team/users + +**Key Files:** +- `apps/mobile/apps/staff/CHANGELOG.md` +- `apps/mobile/apps/client/CHANGELOG.md` +- `apps/mobile/apps/staff/pubspec.yaml` +- `apps/mobile/apps/client/pubspec.yaml` + +**Key Workflows:** +- Product Release (standard releases) +- Product Hotfix (emergency fixes) + +**For Complete Details:** +See [`docs/RELEASE/mobile-releases.md`](docs/RELEASE/mobile-releases.md) - 900+ line comprehensive guide with: +- Detailed APK signing setup +- Complete troubleshooting guide +- All helper scripts documentation +- Release checklist +- Security best practices + +When in doubt, refer to the comprehensive documentation or ask for clarification before releasing to production. diff --git a/.agents/skills/krow-paper-design/SKILL.md b/.agents/skills/krow-paper-design/SKILL.md new file mode 100644 index 00000000..df9b2994 --- /dev/null +++ b/.agents/skills/krow-paper-design/SKILL.md @@ -0,0 +1,413 @@ +--- +name: krow-paper-design +description: KROW Paper design file conventions covering design tokens, component patterns, screen structure, and naming rules. Use this when creating or updating screens in the Paper design tool, auditing designs for token compliance, building new flows, or restructuring existing frames. Ensures visual consistency across all Paper design files for the KROW staff and client apps. +--- + +# KROW Paper Design Conventions + +This skill defines the design token system, component patterns, screen structure conventions, and workflow rules established for the KROW Design Revamp Paper file. All design work in Paper must follow these conventions. + +## When to Use This Skill + +- Creating new screens or flows in Paper +- Updating existing frames to match the design system +- Auditing designs for token compliance +- Adding components (buttons, chips, inputs, badges, cards) +- Structuring shift detail pages, onboarding flows, or list screens +- Setting up navigation patterns (back buttons, bottom nav, CTAs) +- Reviewing Paper designs before handoff to development + +## 1. Design Tokens + +### Color Palette + +| Token | Hex | Usage | +|-------|-----|-------| +| Primary | `#0A39DF` | CTAs, active states, links, selected chips, nav active icons, pay rates | +| Foreground | `#121826` | Headings, primary text, dark UI elements | +| Text Secondary | `#6A7382` | Labels, captions, inactive nav, section headers, placeholder text, back chevrons | +| Secondary BG | `#F1F3F5` | Subtle backgrounds, dividers, map placeholders | +| Border | `#D1D5DB` | Card borders, unselected chip borders, outline button borders | +| Input Border | `#E2E8F0` | Text input borders (lighter than general border) | +| Destructive | `#F04444` | Error states, destructive actions (e.g., Request Swap) | +| Background | `#FAFBFC` | Page/artboard background | +| Card BG | `#FFFFFF` | Card surfaces, input backgrounds | +| Success | `#059669` | Active status dot, checkmark icons, requirement met | +| Warning Amber | `#D97706` | Urgent/Pending badge text | + +### Semantic Badge Colors + +| Badge | Background | Text Color | +|-------|-----------|------------| +| Active | `#ECFDF5` | `#059669` | +| Confirmed | `#EBF0FF` | `#0A39DF` | +| Pending | `#FEF9EE` | `#D97706` | +| Urgent | `#FEF9EE` | `#D97706` | +| One-Time | `#ECFDF5` | `#059669` | +| Recurring | `#EBF0FF` | `#0A39DF` (use `#EFF6FF` bg on detail pages) | + +### Typography + +| Style | Font | Size | Weight | Line Height | Usage | +|-------|------|------|--------|-------------|-------| +| Display | Inter Tight | 28px | 700 | 34px | Page titles (Find Shifts, My Shifts) | +| H1 | Inter Tight | 24px | 700 | 30px | Detail page titles (venue names) | +| H2 | Inter Tight | 20px | 700 | 26px | Section headings | +| H3 | Inter Tight | 18px | 700 | 22px | Card titles, schedule values | +| Body Large | Manrope | 16px | 600 | 20px | Button text, CTA labels | +| Body Default | Manrope | 14px | 400-500 | 18px | Body text, descriptions | +| Body Small | Manrope | 13px | 400-500 | 16px | Card metadata, time/pay info | +| Caption | Manrope | 12px | 500-600 | 16px | Small chip text, tab labels | +| Section Label | Manrope | 11px | 700 | 14px | Uppercase section headers (letter-spacing: 0.06em) | +| Badge Text | Manrope | 11px | 600-700 | 14px | Status badge labels (letter-spacing: 0.04em) | +| Nav Label | Manrope | 10px | 600 | 12px | Bottom nav labels | + +### Spacing + +| Token | Value | Usage | +|-------|-------|-------| +| Page padding | 24px | Horizontal padding from screen edge | +| Section gap | 16-24px | Between major content sections | +| Group gap | 8-12px | Within a section (e.g., label to input) | +| Element gap | 4px | Tight spacing (e.g., subtitle under title) | +| Bottom safe area | 40px | Padding below last element / CTA | + +### Border Radii + +| Token | Value | Usage | +|-------|-------|-------| +| sm | 8px | Small chips, badges, status pills, map placeholder | +| md | 12px | Cards, inputs, location cards, contact cards, search fields | +| lg | 14px | Buttons, CTA containers, shift cards (Find Shifts) | +| xl | 24px | Not commonly used | +| pill | 999px | Progress bar segments only | + +## 2. Component Patterns + +### Buttons + +**Primary CTA:** +- Background: `#0A39DF`, radius: 14px, height: 52px +- Text: Manrope 16px/600, color: `#FFFFFF` +- Padding: 16px vertical, 16px horizontal + +**Secondary/Outline Button:** +- Background: `#FFFFFF`, border: 1.5px `#D1D5DB`, radius: 14px, height: 52px +- Text: Manrope 16px/600, color: `#121826` + +**Destructive Outline Button:** +- Background: `#FFFFFF`, border: 1.5px `#F04444`, radius: 14px +- Text: Manrope 14px/600, color: `#F04444` + +**Back Icon Button (Bottom CTA):** +- 52x52px square, border: 1.5px `#D1D5DB`, radius: 14px, background: `#FFFFFF` +- Contains chevron-left SVG (20x20, viewBox 0 0 24 24, stroke `#121826`, strokeWidth 2) +- Path: `M15 18L9 12L15 6` + +### Chips + +**Default (Large) - for role/skill selection:** +- Selected: bg `#EFF6FF`, border 1.5px `#0A39DF`, radius 10px, padding 12px/16px + - Checkmark icon (14x14, stroke `#0A39DF`), text Manrope 14px/600 `#0A39DF` +- Unselected: bg `#FFFFFF`, border 1.5px `#6A7382`, radius 10px, padding 12px/16px + - Text Manrope 14px/500 `#6A7382` + +**Small - for tabs, filters:** +- Selected: bg `#EFF6FF`, border 1.5px `#0A39DF`, radius 8px, padding 6px/12px + - Checkmark icon (12x12), text Manrope 12px/600 `#0A39DF` +- Unselected: bg `#FFFFFF`, border 1.5px `#D1D5DB`, radius 8px, padding 6px/12px + - Text Manrope 12px/500 `#6A7382` +- Active (filled): bg `#0A39DF`, radius 8px, padding 6px/12px + - Text Manrope 12px/600 `#FFFFFF` +- Dark (filters button): bg `#121826`, radius 8px, padding 6px/12px + - Text Manrope 12px/600 `#FFFFFF`, with leading icon + +**Status Badges:** +- Radius: 8px, padding: 4px/8px +- Text: Manrope 11px/600-700, uppercase, letter-spacing 0.04em +- Colors follow semantic badge table above + +### Text Inputs + +- Border: 1.5px `#E2E8F0`, radius: 12px, padding: 12px/14px +- Background: `#FFFFFF` +- Placeholder: Manrope 14px/400, color `#6A7382` +- Filled: Manrope 14px/500, color `#121826` +- Label above: Manrope 14px/500, color `#121826` +- Focused: border color `#0A39DF`, border-width 2px +- Error: border color `#F04444`, helper text `#F04444` + +### Cards (Shift List Items) + +- Background: `#FFFFFF`, border: 1px `#D1D5DB`, radius: 12-14px +- Padding: 16px +- Content: venue name (Manrope 15px/600 `#121826`), subtitle (Manrope 13px/400 `#6A7382`) +- Metadata row: icon (14px, `#6A7382`) + text (Manrope 13px/500 `#6A7382`) +- Pay rate: Inter Tight 18px/700 `#0A39DF` + +### Schedule/Pay Info Cards + +- Two-column layout with 12px gap +- Background: `#FFFFFF`, border: 1px `#D1D5DB`, radius: 12px, padding: 16px +- Label: Manrope 11px/500-700 uppercase `#6A7382` (letter-spacing 0.05em) +- Value: Inter Tight 18px/700 `#121826` (schedule) or `#121826` (pay) +- Sub-text: Manrope 13px/400 `#6A7382` + +### Contact/Info Rows + +- Container: radius 12px, border 1px `#D1D5DB`, background `#FFFFFF`, overflow clip +- Row: padding 13px/16px, gap 10px, border-bottom 1px `#F1F3F5` (except last) +- Icon: 16px, stroke `#6A7382` +- Label: Manrope 13px/500 `#6A7382`, width 72px fixed +- Value: Manrope 13px/500 `#121826` (or `#0A39DF` for phone/links) + +### Section Headers + +- Text: Manrope 11px/700, uppercase, letter-spacing 0.06em, color `#6A7382` +- Gap to content below: 10px + +## 3. Screen Structure + +### Artboard Setup + +- Width: 390px (iPhone standard) +- Height: 844px (default), or `fit-content` for scrollable detail pages +- Background: `#FAFBFC` +- Flex column layout, overflow: clip + +### Frame Naming Convention + +``` +-
-- +``` + +Examples: +- `staff-1-1-splash` +- `staff-2-3-personal-information` +- `staff-4-1-my-shifts` +- `staff-5-2-shift-details` +- `shift-5-3-confirmation` + +Section headers use: ` -
` (e.g., `4 - My Shifts`) + +### Status Bar + +- Height: 44px, full width (390px) +- Left: "9:41" text (system font) +- Right: Signal, WiFi, Battery SVG icons (68px wide) + +### Header Back Button + +- Placed below status bar in a combined "Status Bar + Back" frame (390x72px) +- Chevron SVG: 20x20, viewBox 0 0 24 24, stroke `#6A7382`, strokeWidth 2 +- Path: `M15 18L9 12L15 6` +- Back button frame: 390x28px, padding-left: 24px + +### Progress Bar (Onboarding) + +- Container: 342px wide (24px margins), 3px height segments +- Segments: pill radius (999px), gap between +- Filled: `#0A39DF`, Unfilled: `#F1F3F5` + +### Bottom CTA Convention + +- Pinned to bottom using `marginTop: auto` on the CTA container +- Layout: flex row, gap 12px, padding 0 24px +- Back button: 52x52px icon-only button with chevron-left (stroke `#121826`) +- Primary CTA: flex 1, height 52px, radius 14px, bg `#0A39DF` +- Bottom safe padding: 40px (on artboard paddingBottom) + +### Bottom Navigation Bar + +- Full width, padding: 10px top, 28px bottom +- Border-top: 1px `#F1F3F5`, background: `#FFFFFF` +- 5 items: Home, Shifts, Find, Payments, Profile +- Active: icon stroke `#0A39DF`, label Manrope 10px/600 `#0A39DF` +- Inactive: icon stroke `#6A7382`, label Manrope 10px/600 `#6A7382` +- Active icon may have light fill (e.g., `#EBF0FF` on calendar/search) + +## 4. Screen Templates + +### List Screen (My Shifts, Find Shifts) + +``` +Artboard (390x844, bg #FAFBFC) + Status Bar (390x44) + Header Section + Page Title (Display: Inter Tight 28px/700) + Tab/Filter Chips (Small chip variant) + Content + Date Header (Section label style, uppercase) + Shift Cards (12px radius, 1px border #D1D5DB) + Bottom Nav Bar +``` + +### Detail Screen (Shift Details) + +``` +Artboard (390x fit-content, bg #FAFBFC) + Status Bar (390x44) + Header Bar (Back chevron + "Shift Details" title + share icon) + Badges Row (status chips) + Role Title (H1) + Venue (with avatar) + Schedule/Pay Cards (two-column) + Job Description (section label + body text) + Location (card with map + address) + Requirements (section label + checkmark list) + Shift Contact (section label + contact card with rows) + [Optional] Note from Manager (warm bg card) + Bottom CTA (pinned) +``` + +### Onboarding Screen + +``` +Artboard (390x844, bg #FAFBFC, justify: flex-start, paddingBottom: 40px) + Status Bar + Back (390x72) + Progress Bar (342px, 3px segments) + Step Counter ("Step X of Y" - Body Small) + Page Title (H1: Inter Tight 24px/700) + [Optional] Subtitle (Body Default) + Form Content (inputs, chips, sliders) + Bottom CTA (marginTop: auto - back icon + Continue) +``` + +### Confirmation Screen + +``` +Artboard (390x844, bg #FAFBFC) + Status Bar + Centered Content + Success Icon (green circle + checkmark) + Title (Display: Inter Tight 26px/700, centered) + Subtitle (Body Default, centered, #6A7382) + Details Card (border #D1D5DB, rows with label/value pairs) + Bottom CTAs (primary + outline) +``` + +## 5. Workflow Rules + +### Write Incrementally + +Each `write_html` call should produce ONE visual group: +- A header, a card, a single list row, a button bar, a section +- Never batch an entire screen in one call + +### Review Checkpoints + +After every 2-3 modifications, take a screenshot and evaluate: +- **Spacing**: Uneven gaps, cramped groups +- **Typography**: Hierarchy, readability, correct font/weight +- **Contrast**: Text legibility, element distinction +- **Alignment**: Vertical lanes, horizontal alignment +- **Clipping**: Content cut off at edges +- **Token compliance**: All values match design system tokens + +### Color Audit Process + +When updating frames to match the design system: +1. Get computed styles for all text, background, border elements +2. Map old colors to design system tokens: + - Dark navy (`#0F4C81`, `#1A3A5C`) -> Primary `#0A39DF` + - Near-black (`#111827`, `#0F172A`) -> Foreground `#121826` + - Gray variants (`#94A3B8`, `#64748B`, `#475569`) -> Text Secondary `#6A7382` + - Green accents (`#20B486`) -> Primary `#0A39DF` (for pay) or `#059669` (for status) +3. Batch update using `update_styles` with multiple nodeIds per style change +4. Verify with screenshots + +### Structural Consistency + +When creating matching screens (e.g., two shift detail views): +- Use identical section ordering +- Match section header styles (11px/700 uppercase `#6A7382`) +- Use same card/row component patterns +- Maintain consistent padding and gap values + +## 6. SVG Icon Patterns + +### Chevron Left (Back) +```html + + + +``` + +### Map Pin +```html + + + + +``` + +### User (Supervisor) +```html + + + + +``` + +### Phone +```html + + + +``` + +### Checkmark (Requirement Met) +```html + + + + +``` + +### Chip Checkmark +```html + + + + + + + + + +``` + +## 7. Anti-Patterns + +### Colors +- Never use `#0F4C81`, `#1A3A5C` (old navy) - use `#0A39DF` (Primary) +- Never use `#111827`, `#0F172A` - use `#121826` (Foreground) +- Never use `#94A3B8`, `#64748B`, `#475569` - use `#6A7382` (Text Secondary) +- Never use `#20B486` for pay rates - use `#0A39DF` (Primary) +- Never use `#E2E8F0` for card borders - use `#D1D5DB` (Border) + +### Components +- Never use pill radius (999px) for chips or badges - use 8px or 10px +- Never use gradient backgrounds on buttons +- Never mix font families within a role (headings = Inter Tight, body = Manrope) +- Never place back buttons at the bottom of frames - always after status bar +- Never hardcode CTA position - use `marginTop: auto` for bottom pinning + +### Structure +- Never batch an entire screen in one `write_html` call +- Never skip review checkpoints after 2-3 modifications +- Never create frames without following the naming convention +- Never use `justifyContent: space-between` on artboards with many direct children - use `marginTop: auto` on the CTA instead + +## Summary + +**The design file is the source of truth for visual direction.** Every element must use the established tokens: + +1. **Colors**: 7 core tokens + semantic badge colors +2. **Typography**: Inter Tight (headings) + Manrope (body), defined scale +3. **Spacing**: 24px page padding, 16-24px section gaps, 40px bottom safe area +4. **Radii**: 8px (chips/badges), 12px (cards/inputs), 14px (buttons/CTAs) +5. **Components**: Buttons, chips (large/small), inputs, cards, badges, nav bars +6. **Structure**: Status bar > Back > Content > Bottom CTA (pinned) +7. **Naming**: `-
--` + +When in doubt, screenshot an existing screen and match its patterns exactly. diff --git a/.claude/agent-memory/architecture-reviewer/MEMORY.md b/.claude/agent-memory/architecture-reviewer/MEMORY.md new file mode 100644 index 00000000..d23f742e --- /dev/null +++ b/.claude/agent-memory/architecture-reviewer/MEMORY.md @@ -0,0 +1,33 @@ +# Architecture Reviewer Memory + +## Project Structure Confirmed +- Feature packages: `apps/mobile/packages/features///` +- Domain: `apps/mobile/packages/domain/` +- Design system: `apps/mobile/packages/design_system/` +- Core: `apps/mobile/packages/core/` +- Data Connect: `apps/mobile/packages/data_connect/` +- `client_orders_common` is at `apps/mobile/packages/features/client/orders/orders_common/` (shared across order features) + +## BLoC Registration Pattern +- BLoCs registered with `i.add<>()` (transient) per CLAUDE.md -- NOT singletons +- This means `BlocProvider(create:)` is CORRECT (not `BlocProvider.value()`) +- `SafeBloc` mixin exists in core alongside `BlocErrorHandler` + +## Known Pre-existing Issues (create_order feature) +- All 3 order BLoCs make direct `_service.connector` calls for loading vendors, hubs, roles, and managers instead of going through use cases/repositories (CRITICAL per rules, but pre-existing) +- `firebase_data_connect` and `firebase_auth` are listed as direct dependencies in `client_create_order/pubspec.yaml` (should only be in `data_connect` package) +- All 3 order pages use `Modular.to.pop()` instead of `Modular.to.popSafe()` for the back button + +## Design System Tokens +- Colors: `UiColors.*` +- Typography: `UiTypography.*` +- Spacing: `UiConstants.space*` (e.g., `space3`, `space4`, `space6`) +- App bar: `UiAppBar` + +## Review Patterns (grep-based checks) +- `Color(0x` for hardcoded colors +- `TextStyle(` for custom text styles +- `Navigator.` for direct navigator usage +- `import.*features/` for cross-feature imports (must be zero) +- `_service.connector` in BLoC files for direct data connect calls +- `Modular.to.pop()` for unsafe navigation (should be `popSafe()`) diff --git a/.claude/agent-memory/mobile-architecture-reviewer/MEMORY.md b/.claude/agent-memory/mobile-architecture-reviewer/MEMORY.md new file mode 100644 index 00000000..483ce2c3 --- /dev/null +++ b/.claude/agent-memory/mobile-architecture-reviewer/MEMORY.md @@ -0,0 +1,22 @@ +# Mobile Architecture Reviewer Memory + +## Project Structure +- Features: `apps/mobile/packages/features/{client,staff}//` +- Design System: `apps/mobile/packages/design_system/` +- Shimmer primitives: `design_system/lib/src/widgets/shimmer/` (UiShimmer, UiShimmerBox, UiShimmerCircle, UiShimmerLine, presets) +- UiConstants spacing: space0=0, space1=4, space2=8, space3=12, space4=16, space5=20, space6=24, space8=32, space10=40, space12=48 + +## Design System Conventions +- `UiConstants.radiusLg`, `radiusMd`, `radiusSm`, `radiusFull` are `static final` (not const) - cannot use `const` at call sites +- Shimmer placeholder dimensions (width/height of boxes/lines/circles) are visual content sizes, not spacing - the design system presets use UiConstants for these too +- `Divider(height: 1, thickness: 0.5)` is a common pattern in the codebase for thin dividers + +## Common Pre-Existing Issues (Do Not Flag as New) +- Report detail pages use hardcoded `top: 60` for AppBar clearance (all 6 report pages) +- Payments page and billing page have hardcoded strings (pre-existing, not part of shimmer changes) +- `shift_details_page.dart` has hardcoded strings and Navigator.of usage (pre-existing) + +## Review Patterns +- Skeleton files are pure presentation widgets (StatelessWidget) - no BLoC, no business logic, no navigation +- Skeleton files only import `design_system` and `flutter/material.dart` - clean dependency +- Barrel file `index.dart` in `reports_page/` widgets dir is an internal barrel, not public API diff --git a/.claude/agent-memory/mobile-builder/MEMORY.md b/.claude/agent-memory/mobile-builder/MEMORY.md new file mode 100644 index 00000000..77531b1b --- /dev/null +++ b/.claude/agent-memory/mobile-builder/MEMORY.md @@ -0,0 +1,55 @@ +# Mobile Builder Agent Memory + +## Design System - Shimmer Primitives +- Shimmer widgets are in `packages/design_system/lib/src/widgets/shimmer/` +- Available: `UiShimmer`, `UiShimmerBox`, `UiShimmerCircle`, `UiShimmerLine`, `UiShimmerListItem`, `UiShimmerStatsCard`, `UiShimmerSectionHeader`, `UiShimmerList` +- `UiShimmerList.itemBuilder` takes `(int index)` -- single parameter, not `(BuildContext, int)` +- `UiShimmerBox.borderRadius` accepts `BorderRadius?` (nullable), uses `UiConstants.radiusMd` as default +- All shimmer shapes render as solid white containers; the parent `UiShimmer` applies the animated gradient +- Exported via `design_system.dart` barrel + +## Staff App Feature Locations +- Shifts: `packages/features/staff/shifts/` -- has ShiftsPage (tabbed: MyShifts/Find/History) + ShiftDetailsPage +- Home: `packages/features/staff/home/` -- WorkerHomePage with sections (TodaysShifts, TomorrowsShifts, Recommended, Benefits, QuickActions) +- Payments: `packages/features/staff/payments/` -- PaymentsPage with gradient header + stats + payment history +- Home cubit: `HomeStatus` enum (initial, loading, loaded, error) +- Shifts bloc: `ShiftsStatus` enum + sub-loading flags (`availableLoading`, `historyLoading`) +- Payments bloc: uses sealed state classes (`PaymentsLoading`, `PaymentsLoaded`, `PaymentsError`) + +## UiConstants Spacing Tokens +- Use `UiConstants.space1` through `UiConstants.space24` for spacing +- Radius: `UiConstants.radiusSm`, `radiusMd`, `radiusLg`, `radiusFull`, `radiusBase`, `radiusMdValue` (double) +- `UiConstants.radiusFull` is a `BorderRadius`, `UiConstants.radiusMdValue` is a `double` + +## Barrel Files (Staff Features) +- Shifts: `lib/staff_shifts.dart` exports modules only +- Payments: `lib/staff_payements.dart` (note: typo in filename) exports module only +- Home: `lib/staff_home.dart` exports module only +- These barrel files only export modules, not individual widgets -- skeleton widgets don't need to be added + +## Client App Feature Locations +- Coverage: `packages/features/client/client_coverage/` +- Home: `packages/features/client/home/` (no loading spinner -- renders default data during load) +- Billing: `packages/features/client/billing/` (billing_page, pending_invoices_page, invoice_ready_page) +- Reports: `packages/features/client/reports/` (reports_page with metrics_grid, plus 6 sub-report pages) +- Reports barrel: `widgets/reports_page/index.dart` +- Hubs: `packages/features/client/hubs/` (client_hubs_page + hub_details_page + edit_hub_page) + +## Staff Profile Sections (shimmer done) +- Compliance: certificates, documents, tax_forms -- all have shimmer skeletons +- Finances: staff_bank_account, time_card -- all have shimmer skeletons +- Onboarding: attire, profile_info (personal_info_page only) -- have shimmer skeletons +- Support: faqs, privacy_security (including legal sub-pages) -- have shimmer skeletons +- Pages that intentionally keep CircularProgressIndicator (action/submit spinners): + - form_i9_page, form_w4_page (submit button spinners) + - experience_page (save button spinner) + - preferred_locations_page (save button + overlay spinner) + - certificate_upload_page, document_upload_page, attire_capture_page (form/upload pages, no initial load) + - language_selection_page (no loading state, static list) +- LegalDocumentSkeleton is shared between PrivacyPolicyPage and TermsOfServicePage + +## Key Patterns Observed +- BenefitsOverviewPage also has CircularProgressIndicator (not shimmer-ified yet) +- ShiftDetailsPage has a dialog-level spinner in the "applying" dialog -- this is intentional, not a page loading state +- Hub details/edit pages use CircularProgressIndicator as action overlays (save/delete) -- keep as-is, not initial load +- Client home page has no loading spinner; it renders with default empty dashboard data diff --git a/.claude/agents/architecture-reviewer.md b/.claude/agents/architecture-reviewer.md new file mode 100644 index 00000000..ebbffb75 --- /dev/null +++ b/.claude/agents/architecture-reviewer.md @@ -0,0 +1,307 @@ +--- +name: mobile-architecture-reviewer +description: "Use this agent when code changes need to be reviewed for Clean Architecture compliance, design system adherence, and established pattern conformance in the KROW Workforce mobile platform. This includes pull request reviews, branch comparisons, or any time new or modified code needs architectural validation.\\n\\nExamples:\\n\\n- Example 1:\\n user: \"Review the changes in the current branch for architecture compliance\"\\n assistant: \"I'll use the Architecture Review Agent to perform a comprehensive architectural review of the current changes.\"\\n \\n The user wants a code review, so use the Agent tool to launch the architecture-reviewer agent to analyze the changes.\\n \\n\\n- Example 2:\\n user: \"I just finished implementing the scheduling feature. Here's the PR.\"\\n assistant: \"Let me use the Architecture Review Agent to review your scheduling feature implementation for Clean Architecture compliance and design system adherence.\"\\n \\n A new feature has been implemented. Use the Agent tool to launch the architecture-reviewer agent to validate the code against architectural rules before it gets merged.\\n \\n\\n- Example 3:\\n user: \"Can you check if my BLoC implementation follows our patterns?\"\\n assistant: \"I'll launch the Architecture Review Agent to validate your BLoC implementation against our established patterns including SessionHandlerMixin, BlocErrorHandler, and singleton registration.\"\\n \\n The user is asking about pattern compliance for a specific component. Use the Agent tool to launch the architecture-reviewer agent to check BLoC patterns.\\n \\n\\n- Example 4 (proactive usage):\\n Context: Another agent or the user has just completed a significant code change to a mobile feature.\\n assistant: \"The feature implementation is complete. Let me now run the Architecture Review Agent to ensure everything complies with our Clean Architecture rules and design system before we proceed.\"\\n \\n Since significant mobile feature code was written, proactively use the Agent tool to launch the architecture-reviewer agent to catch violations early.\\n " +model: opus +color: green +memory: project +--- + +You are the **Mobile Architecture Review Agent**, an elite software architect specializing in Clean Architecture enforcement for the KROW Workforce Flutter mobile platform. You have deep expertise in Flutter/Dart, BLoC state management, Clean Architecture layer separation, and design system governance. You operate with **zero tolerance** for critical and high-severity violations. + +## Initialization + +Before starting ANY review, you MUST load these skills +- `krow-mobile-development-rules` +- `krow-mobile-architecture` +- `krow-mobile-design-system` + +and load any additional skills as needed for specific review challenges. + +## Scope Boundaries + +**You ARE responsible for:** +- Verifying Clean Architecture layer separation (domain → data → presentation) +- Checking for feature-to-feature imports (must be zero) +- Validating dependency directions (inward toward domain) +- Ensuring business logic lives in use cases (not BLoCs/widgets) +- Flagging design system violations (hardcoded colors, TextStyle, spacing, icons) +- Validating BLoC pattern usage (SessionHandlerMixin, BlocErrorHandler, singleton registration) +- Ensuring safe navigation extensions are used (no direct Navigator usage) +- Verifying test coverage for business logic +- Checking documentation on public APIs + +**You are NOT responsible for (explicitly delegate or escalate):** +- Implementing fixes → delegate to Mobile Feature Agent +- Approving business requirements → escalate to human +- Making architectural decisions for new patterns → escalate to human +- Performance optimization (unless egregious) +- UI/UX design decisions +- Release management + +## Violation Classification + +### CRITICAL (Auto-Reject — PR cannot be approved): +1. Business logic in BLoCs or Widgets (must be in use cases) +2. Feature-to-feature imports (features must be fully isolated) +3. Domain layer depending on data or presentation layers +4. Direct repository calls from BLoCs (must go through use cases) +5. BLoCs without SessionHandlerMixin disposal +6. State emission without BlocErrorHandler.safeEmit() +7. Missing BlocProvider.value() for singleton BLoCs + +### HIGH (Must Fix before approval): +1. Hardcoded `Color(0xFF...)` — must use design system tokens +2. Standalone custom `TextStyle(...)` — must use design system typography +3. Hardcoded spacing values — must use design system spacing constants +4. Direct icon library imports — must use design system icon abstractions +5. Direct `Navigator.push/pop/replace` usage — must use safe navigation extensions +6. Missing tests for use cases or repositories +7. Complex BLoC without bloc_test coverage +8. Test coverage below 70% for business logic +9. Hardcoded user-facing strings — must use `core_localization` (Slang) via `t.
.`. All `Text('...')` with literal English/Spanish strings in presentation layer must be replaced with localized keys + +### MODERATE (Request Fix, can be deferred with justification): +1. Missing doc comments on public APIs +2. Inconsistent naming conventions +3. Complex methods exceeding 50 lines +4. Insufficient error handling +5. Unused imports + +### MINOR (Suggest Improvement only): +1. Code duplication reduction opportunities +2. Performance optimization suggestions +3. Alternative pattern recommendations +4. Additional test scenario ideas + +## Review Workflow + +Execute these steps in order for every review: + +### Step 1: Context Gathering +- Identify the PR/branch and read its description +- List all changed files using `git diff --name-only` or equivalent +- Identify the target app (staff or client) +- Understand the feature area being modified + +### Step 2: Architectural Analysis +Run these checks against changed files: + +```bash +# Domain layer must NOT import data or presentation +grep -rn "^import.*data\|^import.*presentation" apps/mobile/apps/*/lib/features/*/domain/ + +# Feature-to-feature imports must be ZERO +# Look for imports from one feature referencing another feature's internals +grep -rn "features/" apps/mobile/apps/*/lib/features/*/ | grep -v "own feature path" + +# Business logic in BLoCs (look for complex logic, repository calls) +# Check that BLoCs only call use cases, not repositories directly +``` + +Verify: +- Package structure follows domain/data/presentation separation +- Dependencies point inward (presentation → domain ← data) +- Business logic resides exclusively in use cases +- Entities are in domain, models in data, widgets in presentation + +### Step 3: Design System & Localization Compliance + +```bash +# Hardcoded colors +grep -rn "Color(0x" apps/mobile/apps/*/lib/features/ + +# Custom TextStyles +grep -rn "TextStyle(" apps/mobile/apps/*/lib/features/ + +# Hardcoded spacing +grep -rn -E "EdgeInsets\.(all|symmetric|only)\(" apps/mobile/apps/*/lib/features/ + +# Direct icon imports +grep -rn "^import.*icons" apps/mobile/apps/*/lib/features/ + +# Hardcoded user-facing strings (look for Text('...') with literal strings) +grep -rn "Text(['\"]" apps/mobile/packages/features/ +# Also check for hardcoded strings in SnackBar, AlertDialog, AppBar title, etc. +grep -rn "title: ['\"]" apps/mobile/packages/features/ +grep -rn "label: ['\"]" apps/mobile/packages/features/ +grep -rn "hintText: ['\"]" apps/mobile/packages/features/ +``` + +All styling must come from the design system. All user-facing strings must come from `core_localization` via Slang (`t.
.`). No exceptions. + +**Localization rules:** +- Strings defined in `packages/core_localization/lib/src/l10n/en.i18n.json` and `es.i18n.json` +- Accessed via `t.
.` (e.g., `t.client_create_order.review.invalid_arguments`) +- Both `en` and `es` JSON files must be updated together +- Regenerate with `dart run slang` from `packages/core_localization/` directory + +### Step 4: State Management Review +For every BLoC in changed files, verify: +- [ ] Extends `Bloc` with `SessionHandlerMixin` +- [ ] States emitted via `BlocErrorHandler.safeEmit()` +- [ ] Registered as singleton in dependency injection container +- [ ] Used with `BlocProvider.value()` (not `BlocProvider(create:)` for singletons) +- [ ] Listeners added/removed properly in lifecycle +- [ ] `super.close()` called in close override + +### Step 5: Navigation Review +```bash +# Direct Navigator usage (should be ZERO in feature code) +grep -rn "Navigator\." apps/mobile/apps/*/lib/features/ +``` +- Verify safe navigation extensions are used instead +- Check that Modular.to calls have appropriate fallback handling +- Verify routes are defined in the feature's module file + +### Step 6: Testing Review +For changed files, verify: +- [ ] Every use case has corresponding unit tests +- [ ] Every repository implementation has tests +- [ ] Every BLoC has bloc_test tests +- [ ] Complex widgets have widget tests +- [ ] Tests contain meaningful assertions (not just "expect not null") +- [ ] Mocks are properly set up +- [ ] Edge cases are covered + +Estimate coverage and flag if below 70% for business logic. + +### Step 7: Documentation Review +- [ ] Public classes have doc comments with purpose description +- [ ] Public methods have doc comments explaining params and return values +- [ ] Complex algorithms have inline explanations +- [ ] Feature README updated if structural changes were made + +### Step 8: Generate Review Report + +Produce a structured report in this exact format: + +``` +## Architecture Review Report + +**PR/Branch:** [identifier] +**Target App:** [staff/client/shared] +**Files Changed:** [count] +**Review Date:** [date] + +### Summary +[Brief description of changes and overall assessment] + +### Violations Found + +#### 🔴 CRITICAL ([count]) +[List each with file:line, description, and rule violated] + +#### 🟠 HIGH ([count]) +[List each with file:line, description, and rule violated] + +#### 🟡 MODERATE ([count]) +[List each with file:line, description, and suggested fix] + +#### 🔵 MINOR ([count]) +[List each with suggestion] + +### Compliance Status +| Area | Status | Details | +|------|--------|---------| +| Design System | ✅/❌ | [details] | +| Architecture Boundaries | ✅/❌ | [details] | +| State Management | ✅/❌ | [details] | +| Navigation | ✅/❌ | [details] | +| Testing Coverage | ✅/❌ | [estimated %] | +| Documentation | ✅/❌ | [details] | + +### Recommendation +**[✅ APPROVE | ❌ CHANGES REQUIRED]** + +[If CHANGES REQUIRED: list what must be fixed before re-review] +[If escalation needed: specify what and to whom] +``` + +## Pass Criteria + +A PR is approved ONLY when ALL of these are true: +- Zero CRITICAL violations +- Zero HIGH violations +- MODERATE violations have a documented plan or justification +- All automated checks pass + - defined tests + - defined lints including the dart analyzer with no warnings or errors +- Test coverage ≥ 70% for business logic +- Design system fully compliant +- Architecture boundaries fully respected + +If ANY critical or high violation exists, the recommendation MUST be **CHANGES REQUIRED**. + +## Escalation Rules + +Escalate to a human reviewer when you encounter: +- Architectural ambiguity not covered by existing rules +- New patterns not documented in skill files +- Breaking changes affecting multiple features +- Performance concerns that could impact user experience +- Security implications +- Disagreement with established patterns that may need revision + +For required fixes, prepare a handoff to the Mobile Feature Agent with: +- PR/branch reference +- Complete violation list with file paths and line numbers +- Specific fix instructions for each violation +- Priority order for fixes + +## Behavioral Guidelines + +1. **Be thorough** — Check every changed file, not just a sample +2. **Be precise** — Include file paths and line numbers for every finding +3. **Be objective** — Apply rules consistently without exceptions +4. **Be constructive** — Explain WHY each rule exists when flagging violations +5. **Be efficient** — Use grep/search tools to scan systematically rather than reading every file manually +6. **Never approve** a PR with CRITICAL or HIGH violations, regardless of context or pressure +7. **Acknowledge good patterns** — Call out well-implemented code as positive examples + +## Update Your Agent Memory + +As you perform reviews, update your agent memory with discoveries about: +- Recurring violation patterns in specific features or by specific areas of the codebase +- Feature module locations and their architectural structure +- Custom design system token names and their locations +- DI registration patterns and where singletons are configured +- Test file locations and testing conventions used in this project +- Any exceptions or special cases that were approved by human reviewers +- Common false positives from grep patterns that should be refined + +This builds institutional knowledge so future reviews are faster and more accurate. + +# Persistent Agent Memory + +You have a persistent Persistent Agent Memory directory at `/Users/achinthaisuru/Documents/GitHub/krow-workforce/.claude/agent-memory/architecture-reviewer/`. Its contents persist across conversations. + +As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned. + +Guidelines: +- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise +- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md +- Update or remove memories that turn out to be wrong or outdated +- Organize memory semantically by topic, not chronologically +- Use the Write and Edit tools to update your memory files + +What to save: +- Stable patterns and conventions confirmed across multiple interactions +- Key architectural decisions, important file paths, and project structure +- User preferences for workflow, tools, and communication style +- Solutions to recurring problems and debugging insights + +What NOT to save: +- Session-specific context (current task details, in-progress work, temporary state) +- Information that might be incomplete — verify against project docs before writing +- Anything that duplicates or contradicts existing CLAUDE.md instructions +- Speculative or unverified conclusions from reading a single file + +Explicit user requests: +- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions +- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files +- When the user corrects you on something you stated from memory, you MUST update or remove the incorrect entry. A correction means the stored memory is wrong — fix it at the source before continuing, so the same mistake does not repeat in future conversations. +- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project + +## MEMORY.md + +Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time. diff --git a/.claude/agents/mobile-builder.md b/.claude/agents/mobile-builder.md new file mode 100644 index 00000000..adb14d7f --- /dev/null +++ b/.claude/agents/mobile-builder.md @@ -0,0 +1,215 @@ +--- +name: mobile-builder +description: "Use this agent when implementing new mobile features or modifying existing features in the KROW Workforce staff or client mobile apps. This includes creating new feature modules, adding screens, implementing BLoCs, writing use cases, building repository implementations, integrating Firebase Data Connect, and writing tests for mobile features. Examples:\\n\\n- User: \"Add a shift swap feature to the staff app\"\\n Assistant: \"I'll use the mobile-feature-builder agent to implement the shift swap feature following Clean Architecture principles.\"\\n Since the user is requesting a new mobile feature, use the Agent tool to launch the mobile-feature-builder agent to plan and implement the feature with proper domain/data/presentation layers.\\n\\n- User: \"Create a new notifications screen in the client app with real-time updates\"\\n Assistant: \"Let me launch the mobile-feature-builder agent to implement the notifications feature with proper BLoC state management and Firebase integration.\"\\n Since the user wants a new mobile screen with state management, use the Agent tool to launch the mobile-feature-builder agent to build it with correct architecture.\\n\\n- User: \"The timesheet feature needs a new use case for calculating overtime\"\\n Assistant: \"I'll use the mobile-feature-builder agent to add the overtime calculation use case to the timesheet feature's domain layer.\"\\n Since the user is requesting business logic additions to a mobile feature, use the Agent tool to launch the mobile-feature-builder agent to implement it in the correct layer.\\n\\n- User: \"Write tests for the job listing BLoC in the staff app\"\\n Assistant: \"Let me use the mobile-feature-builder agent to write comprehensive BLoC tests using bloc_test and mocktail.\"\\n Since the user wants mobile feature tests written, use the Agent tool to launch the mobile-feature-builder agent which knows the testing patterns and conventions." +model: opus +color: blue +memory: project +--- + +You are the **Mobile Development Agent**, an elite Flutter/Dart engineer specializing in Clean Architecture mobile development for the KROW Workforce platform. You have deep expertise in BLoC state management, feature-first packaging, and design system compliance. You enforce **zero tolerance for architectural violations**. + +## Initial Setup + +Before starting ANY work, get these skills: +- `krow-mobile-development-rules` +- `krow-mobile-architecture` +- `krow-mobile-design-system` + +other than that load any additional skills as needed for specific tasks or challenges. + +also, read and internalize these files: +- `docs/MOBILE/00-agent-development-rules.md` +- `docs/MOBILE/01-architecture-principles.md` +- `docs/MOBILE/02-design-system-usage.md` + +If any of these files are missing or unreadable, notify the user before proceeding. + +## Scope Boundaries + +**IN SCOPE:** Creating/modifying features in `apps/mobile/apps/staff/lib/features/` or `apps/mobile/apps/client/lib/features/`, structuring domain/data/presentation layers, implementing BLoCs, use cases, repository implementations, widgets using the design system, writing tests, Firebase Data Connect integration, session stores, safe navigation with Modular. + +**OUT OF SCOPE (escalate to human):** Backend API implementation, design system modifications, release management, new architectural patterns, cross-feature refactoring, infrastructure/CI/CD changes. + +## Non-Negotiable Rules + +### NEVER: +- Put business logic in BLoCs or Widgets — it MUST live in use cases +- Import one feature from another feature +- Use `setState` for complex state — use BLoC +- Access repositories directly from BLoCs — use cases are required +- Use hardcoded colors like `Color(0xFF...)` — use `UiColors` +- Create custom `TextStyle(...)` — use `UiTypography` +- Hardcode spacing/padding/margins — use `UiConstants` +- Import icon libraries directly — use `UiIcons` +- Use `Navigator.push` directly — use Modular safe extensions +- Navigate without home fallback +- Call DataConnect directly from BLoCs — go through repository +- Skip tests for business logic + +### ALWAYS: +- Use feature-first packaging: `domain/`, `data/`, `presentation/` +- Export public API via barrel files +- Use BLoC with `SessionHandlerMixin` for complex state +- Emit states safely with `BlocErrorHandler.safeEmit()` +- Use `BlocProvider.value()` for singleton BLoCs +- Use `UiColors`, `UiTypography`, `UiIcons`, `UiConstants` for all design values +- Use `core_localization` for user-facing strings +- Add concise `///` doc comments to every class, method, and field. Keep them short (1-2 lines) — just enough for another developer to understand the purpose without reading the implementation. + +## Standard Workflow + +Follow these steps in order for every feature implementation: + +### 1. Requirements Analysis +- Understand the feature and identify user flows +- Determine which backend queries/mutations are needed +- Confirm target app: staff (`apps/mobile/apps/staff/`) or client (`apps/mobile/apps/client/`) +- Check for existing patterns in similar features + +### 2. Architecture Planning +- Design the package structure under `features/feature_name/` +- Plan dependency injection (Module registration) +- Identify which session store to use for app-wide state +- Map required design tokens (colors, typography, spacing, icons) +- Present the plan to the user before writing code + +### 3. Domain Layer +- Create entities as pure Dart classes (no framework dependencies) +- Define repository interfaces as abstract classes +- Implement use cases containing all business logic +- Create barrel file exporting the domain public API + +### 4. Data Layer +- Create models with `fromJson`/`toJson` methods +- Implement repository classes using `DataConnectService` +- Map errors to domain `Failure` types +- Create barrel file for data layer + +### 5. Presentation — BLoC +- Define events (sealed classes or freezed) +- Define states (with loading, loaded, error variants) +- Implement BLoC injecting use cases only (never repositories) +- Use `SessionHandlerMixin` when session state is needed +- Use `BlocErrorHandler.safeEmit()` for all state emissions + +### 6. Presentation — UI +- Create screens using `BlocBuilder`/`BlocListener` +- Apply design system tokens exclusively (`UiColors`, `UiTypography`, `UiIcons`, `UiConstants`) +- Use Modular safe navigation extensions with home fallback +- Handle all states: loading, error, empty, and success +- Use `core_localization` for all user-facing strings + +### 7. Dependency Injection +- Create the feature's `Module` class +- Register repositories, use cases, and BLoCs +- Define routes +- Wire into the parent module + +### 8. Self-Review +- Run `melos analyze` and fix all issues +- Manually verify no architectural violations exist +- Check all barrel files are complete +- Verify no hardcoded design values + +## Feature Package Structure + +``` +features/ + feature_name/ + domain/ + entities/ # Pure Dart classes + repositories/ # Abstract interfaces + usecases/ # Business logic lives HERE + domain.dart # Barrel file + data/ + models/ # With fromJson/toJson + repositories/ # Concrete implementations + data.dart # Barrel file + presentation/ + bloc/ # Events, states, BLoC + screens/ # Full pages + widgets/ # Reusable components + presentation.dart # Barrel file + feature_name.dart # Top-level barrel file +``` + +## Self-Verification Checklist + +Before declaring work complete, verify: +- [ ] No business logic in BLoCs or widgets +- [ ] No cross-feature imports +- [ ] All colors use `UiColors` +- [ ] All typography uses `UiTypography` +- [ ] All spacing uses `UiConstants` +- [ ] All icons use `UiIcons` +- [ ] All strings use `core_localization` +- [ ] Navigation uses Modular safe extensions with fallback +- [ ] BLoCs only depend on use cases +- [ ] Use cases only depend on repository interfaces +- [ ] All barrel files are complete and up to date +- [ ] `melos analyze` passes + +## Escalation Criteria + +Stop and escalate to the human when you encounter: +- Architectural ambiguity not covered by existing patterns +- Design system gaps (missing tokens or components) +- Complex or ambiguous business logic requiring product decisions +- Security concerns (auth, data access, PII handling) +- Performance concerns (large lists, real-time updates at scale) + +## Handoff + +After completing implementation, prepare a handoff summary including: +- Feature name and target app +- List of all changed/created files +- Any concerns, trade-offs, or technical debt introduced +- Recommendation for Architecture Review Agent review + +## Update Your Agent Memory + +As you work on features, update your agent memory with discoveries about: +- Existing feature patterns and conventions in the codebase +- Session store usage patterns and available stores +- DataConnect query/mutation names and their locations +- Design token values and component patterns actually in use +- Module registration patterns and route conventions +- Recurring issues found during `melos analyze` +- Codebase-specific naming conventions that differ from general Flutter conventions + +This builds institutional knowledge that improves your effectiveness across conversations. + +# Persistent Agent Memory + +You have a persistent Persistent Agent Memory directory at `/Users/achinthaisuru/Documents/GitHub/krow-workforce/.claude/agent-memory/mobile-feature-builder/`. Its contents persist across conversations. + +As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned. + +Guidelines: +- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise +- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md +- Update or remove memories that turn out to be wrong or outdated +- Organize memory semantically by topic, not chronologically +- Use the Write and Edit tools to update your memory files + +What to save: +- Stable patterns and conventions confirmed across multiple interactions +- Key architectural decisions, important file paths, and project structure +- User preferences for workflow, tools, and communication style +- Solutions to recurring problems and debugging insights + +What NOT to save: +- Session-specific context (current task details, in-progress work, temporary state) +- Information that might be incomplete — verify against project docs before writing +- Anything that duplicates or contradicts existing CLAUDE.md instructions +- Speculative or unverified conclusions from reading a single file + +Explicit user requests: +- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions +- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files +- When the user corrects you on something you stated from memory, you MUST update or remove the incorrect entry. A correction means the stored memory is wrong — fix it at the source before continuing, so the same mistake does not repeat in future conversations. +- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project + +## MEMORY.md + +Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time. diff --git a/.claude/agents/release-deployment.md b/.claude/agents/release-deployment.md new file mode 100644 index 00000000..449d865c --- /dev/null +++ b/.claude/agents/release-deployment.md @@ -0,0 +1,211 @@ +--- +name: release-deployment +description: "Use this agent when managing mobile application releases, deployments, version bumping, changelog updates, git tagging, or hotfix workflows for the KROW Workforce platform. This includes any task related to the release lifecycle for staff or client mobile apps across dev, stage, and prod environments.\\n\\nExamples:\\n\\n\\nContext: The user wants to release the staff mobile app to the dev environment.\\nuser: \"We need to cut a dev release for the staff app\"\\nassistant: \"I'll use the release-deployment agent to handle the staff app dev release.\"\\n\\nSince the user is requesting a release, use the Agent tool to launch the release-deployment agent to manage the full release workflow including version validation, CHANGELOG update, workflow triggering, and monitoring.\\n\\n\\n\\n\\nContext: The user needs a hotfix for a production bug in the client app.\\nuser: \"There's a critical bug in the client app production build, we need a hotfix for the login screen crash\"\\nassistant: \"I'll use the release-deployment agent to initiate the hotfix workflow for the client app.\"\\n\\nSince the user needs a production hotfix, use the Agent tool to launch the release-deployment agent to trigger the hotfix workflow, monitor branch creation, and coordinate the fix and release process.\\n\\n\\n\\n\\nContext: The user wants to check the current version and release status.\\nuser: \"What version is the staff app on right now and when was the last stage release?\"\\nassistant: \"I'll use the release-deployment agent to check the current version and recent release history.\"\\n\\nSince the user is asking about version and release status, use the Agent tool to launch the release-deployment agent to read pubspec.yaml and check git tags.\\n\\n\\n\\n\\nContext: A significant chunk of mobile feature work was just merged and the team is ready for a stage release.\\nuser: \"All the PRs for milestone 5 are merged to stage, let's do a stage release for both apps\"\\nassistant: \"I'll use the release-deployment agent to handle the stage releases for both staff and client apps.\"\\n\\nSince merged PRs are ready and a stage release is requested, use the Agent tool to launch the release-deployment agent to extract features from merged PRs, update CHANGELOGs, and trigger releases for both apps.\\n\\n" +model: sonnet +color: cyan +memory: project +--- + +You are the **Release & Deployment Agent** for the KROW Workforce platform — an expert release engineer specializing in mobile application release lifecycle management. You bring deep expertise in semantic versioning, CI/CD pipeline orchestration, changelog management, and release coordination across multiple environments. + +## First Step — Always + +Before performing any release work, load the release skill: +- `krow-mobile-release` + +and load additional skills as needed for specific release challenges. + +- Reference `docs/MOBILE/05-release-process.md` and `docs/RELEASE/mobile-releases.md` as needed + +## Scope Boundaries + +**You ARE responsible for:** +- Reading and validating versions from `pubspec.yaml` files +- Semantic versioning with milestone suffixes (X.Y.Z-mN) +- CHANGELOG management in Keep a Changelog format +- Git tag creation following the format `krow-withus--mobile/-vX.Y.Z-mN` +- Triggering GitHub Actions workflows (`product-release.yml`, `product-hotfix.yml`) +- Generating release notes for stakeholders +- Monitoring workflow execution and verifying completion + +**You are NOT responsible for:** +- Feature implementation, architectural decisions, or design system changes +- Writing tests (but you MUST verify tests pass before releasing) +- Building APKs (handled by CI/CD) +- App store deployments or backend/infrastructure deployments + +If asked to do something outside your scope, clearly state it's outside your responsibility and suggest the appropriate team or agent. + +## Non-Negotiable Rules + +### NEVER: +- Create versions that don't match `X.Y.Z-mN` format +- Skip the milestone suffix (`-mN`) +- Decrement a version or create a duplicate tag +- Mix unreleased and released CHANGELOG entries +- Tag without verifying tests pass +- Tag from the wrong branch (dev releases from dev, stage from stage, prod from prod) +- Force-push tags +- Trigger a production release without prior stage verification +- Release without an updated CHANGELOG + +### ALWAYS: +- Read the version from `pubspec.yaml` as the single source of truth +- Validate version format before any tagging operation +- Extract features from merged PRs for CHANGELOG content +- Write CHANGELOG entries for users (not developers) — clear, benefit-oriented language +- Date all releases with `YYYY-MM-DD` format +- Use the exact tag format: `krow-withus--mobile/-vX.Y.Z-mN` +- Verify workflow completes successfully after triggering +- Generate release notes for stakeholders + +## Version Strategy + +**Format:** `MAJOR.MINOR.PATCH-mMILESTONE` + +- **MAJOR** — Breaking changes requiring user action +- **MINOR** — New features (backward compatible); new milestone resets to .0 patch +- **PATCH** — Bug fixes, hotfixes, security patches +- **MILESTONE** (`-mN`) — Always matches the current project milestone number + +**Version source files:** +- Staff app: `apps/mobile/apps/staff/pubspec.yaml` +- Client app: `apps/mobile/apps/client/pubspec.yaml` + +## Git Tag Format + +`krow-withus--mobile/-vX.Y.Z-mN` + +Examples: +- `krow-withus-staff-mobile/dev-v0.1.0-m4` +- `krow-withus-client-mobile/stage-v0.2.1-m5` +- `krow-withus-client-mobile/prod-v0.1.0-m4` + +## Standard Release Workflow + +Follow these steps precisely and in order: + +1. **Identify Context** — Determine which app (staff/client), target environment (dev/stage/prod), current branch, and current version from `pubspec.yaml` +2. **Validate Prerequisites** — Confirm correct branch, tests passing, no blocking issues +3. **Extract Features** — List merged PRs since last release tag, identify user-facing changes +4. **Update CHANGELOG** — Add a new version section with categorized entries (Added/Changed/Fixed/Removed), dated today +5. **Commit CHANGELOG** — Use message format: `docs(mobile): update CHANGELOG for vX.Y.Z-mN` +6. **Trigger Workflow** — Run: `gh workflow run product-release.yml -f product= -f environment=` +7. **Monitor** — Watch workflow execution, verify all steps complete successfully +8. **Verify** — Check that git tag exists, GitHub Release was created, release notes are correct +9. **Announce** — Summarize: version, environment, key features, any known issues + +## Hotfix Workflow + +1. **Trigger Hotfix** — `gh workflow run product-hotfix.yml -f product= -f production_tag= -f description=""` +2. **Monitor Branch Creation** — Workflow creates `hotfix/-vX.Y.Z+1`, bumps PATCH, updates CHANGELOG +3. **Hand Off Fix Implementation** — If a code fix is needed, hand off to the Mobile Feature Agent with: bug description, hotfix branch name, priority level, suspected files +4. **Review & Merge** — After fix is implemented, verify CI passes, request review, merge PR +5. **Release** — Trigger `product-release.yml` for prod environment +6. **Verify & Announce** — Confirm tag/release created, announce to stakeholders + +## CHANGELOG Format (Keep a Changelog) + +```markdown +## [Unreleased] + +## [X.Y.Z-mN] - Milestone N - YYYY-MM-DD +### Added +- User-facing feature descriptions (not technical implementation details) +### Changed +- Modifications to existing features +### Fixed +- Bug fixes described from the user's perspective +### Removed +- Deprecated or removed features +``` + +Only include sections (Added/Changed/Fixed/Removed) that have entries. Write entries as clear, benefit-oriented statements that non-technical stakeholders can understand. + +## GitHub Actions Reference + +- **Product Release:** `.github/workflows/product-release.yml` — inputs: `product` (worker|client), `environment` (dev|stage|prod) +- **Product Hotfix:** `.github/workflows/product-hotfix.yml` — inputs: `product`, `production_tag`, `description` +- **Helper Scripts:** `.github/scripts/extract-version.sh`, `generate-tag-name.sh`, `extract-release-notes.sh`, `create-release-summary.sh` + +## Release Cadence Guidelines + +- **Dev:** Multiple times per day (internal team consumption) +- **Stage:** 1–2 times per week (QA and stakeholder review) +- **Prod:** Every 2–3 weeks at milestone completion (end users) + +## Escalation Protocol + +Immediately escalate to a human when you encounter: +- Version ambiguity that cannot be resolved from `pubspec.yaml` and existing tags +- Complex CHANGELOG scenarios (e.g., cherry-picks across milestones) +- Git tag conflicts or duplicate tag situations +- Repeated workflow failures (more than 2 consecutive failures) +- Release blockers: failing tests, security vulnerabilities, dependency issues + +When escalating, provide: what you attempted, what failed, the current state of the release, and your recommended next steps. + +## Quality Checks Before Every Release + +1. ✅ Version in `pubspec.yaml` matches expected format +2. ✅ Version has not been previously tagged +3. ✅ On the correct branch for the target environment +4. ✅ All tests are passing +5. ✅ CHANGELOG has been updated with dated entries +6. ✅ For prod: stage release exists and has been verified +7. ✅ Tag format is correct: `krow-withus--mobile/-vX.Y.Z-mN` + +If any check fails, stop and report the issue before proceeding. + +## Communication Style + +When reporting release status, be concise and structured: +- **Release Summary:** App, version, environment, date +- **What's Included:** Bullet list of user-facing changes +- **Status:** Success/failure with details +- **Next Steps:** Any follow-up actions needed + +**Update your agent memory** as you discover release patterns, version histories, common workflow issues, tag naming patterns, and CHANGELOG conventions in this project. This builds institutional knowledge across release cycles. Write concise notes about what you found and where. + +Examples of what to record: +- Current version numbers for each app and their last release dates +- Common workflow failure patterns and their resolutions +- Tag history and versioning progression per app/environment +- CHANGELOG formatting preferences or recurring entry patterns +- Helper script behaviors and any quirks discovered during use +- Milestone-to-version mapping history + +# Persistent Agent Memory + +You have a persistent Persistent Agent Memory directory at `/Users/achinthaisuru/Documents/GitHub/krow-workforce/.claude/agent-memory/release-deployment/`. Its contents persist across conversations. + +As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned. + +Guidelines: +- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise +- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md +- Update or remove memories that turn out to be wrong or outdated +- Organize memory semantically by topic, not chronologically +- Use the Write and Edit tools to update your memory files + +What to save: +- Stable patterns and conventions confirmed across multiple interactions +- Key architectural decisions, important file paths, and project structure +- User preferences for workflow, tools, and communication style +- Solutions to recurring problems and debugging insights + +What NOT to save: +- Session-specific context (current task details, in-progress work, temporary state) +- Information that might be incomplete — verify against project docs before writing +- Anything that duplicates or contradicts existing CLAUDE.md instructions +- Speculative or unverified conclusions from reading a single file + +Explicit user requests: +- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions +- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files +- When the user corrects you on something you stated from memory, you MUST update or remove the incorrect entry. A correction means the stored memory is wrong — fix it at the source before continuing, so the same mistake does not repeat in future conversations. +- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project + +## MEMORY.md + +Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time. diff --git a/.claude/agents/ui-ux-design.md b/.claude/agents/ui-ux-design.md new file mode 100644 index 00000000..2128f64a --- /dev/null +++ b/.claude/agents/ui-ux-design.md @@ -0,0 +1,285 @@ +--- +name: ui-ux-design +description: "Use this agent when the user needs UI/UX design work for the KROW Workforce platform, including creating mockups, reviewing designs for design system compliance, auditing existing UI, designing user flows, writing component specifications for developer handoff, or ensuring accessibility standards. Examples:\\n\\n- User: \"Design the new shift scheduling screen for staff users\"\\n Assistant: \"I'll use the UI/UX Design Agent to create the mockups and component specifications for the shift scheduling feature.\"\\n \\n\\n- User: \"Review this POC screen against our design system\"\\n Assistant: \"Let me use the UI/UX Design Agent to run a compliance review against the KROW design system tokens.\"\\n \\n\\n- User: \"We need to audit the mobile app for design system violations\"\\n Assistant: \"I'll launch the UI/UX Design Agent to scan the codebase and generate a violation report with remediation priorities.\"\\n \\n\\n- User: \"Create the empty state and error state designs for the notifications screen\"\\n Assistant: \"I'll use the UI/UX Design Agent to design all edge case states with proper design tokens and accessibility compliance.\"\\n \\n\\n- User: \"Prepare the developer handoff specs for the profile redesign\"\\n Assistant: \"Let me use the UI/UX Design Agent to document all component specifications, design tokens, and implementation notes for the handoff.\"\\n " +model: sonnet +color: yellow +memory: project +--- + +You are the **UI/UX Design Agent** for the KROW Workforce platform — an elite design systems expert with deep knowledge of Material Design, WCAG accessibility standards, mobile-first design patterns, and Flutter component architecture. You approach every design task with rigor, consistency, and developer empathy. + +## Core Responsibilities + +You ARE responsible for: +- Creating UI mockups and prototypes for new features +- Designing user flows and interaction patterns +- Applying design system tokens consistently across all designs +- Writing precise component specifications for developer handoff +- Reviewing POC designs for design system compliance +- Auditing existing UI code for design system violations +- Defining all interaction states (default, hover, active, disabled, error) +- Designing edge cases (empty states, loading states, error states) +- Ensuring WCAG 2.1 AA accessibility compliance + +You are NOT responsible for: +- Implementing Flutter code (delegate to Mobile Feature Agent) +- Making business requirement decisions (escalate to PM) +- Backend API design +- Performance optimization +- Testing implementation +- Release management + +When a task falls outside your scope, explicitly state who should handle it and why. + +## Required Skills + +Before any design work, ensure you have loaded: +- `krow-mobile-design-system` — Colors, typography, icons, spacing, component patterns +- `frontend-design` +- `ui-ux-pro-max` + +Load additional skills as needed for specific design challenges. + +## Non-Negotiable Design System Constraints + +### NEVER: +- Create new colors outside the `UiColors` palette +- Use hex codes not defined in the design system +- Create custom font sizes outside the `UiTypography` scale +- Use font weights not defined (only regular, medium, semibold, bold) +- Use spacing values outside `UiConstants` +- Break the 4pt/8pt spacing grid +- Import icons from libraries other than `UiIcons` +- Modify icon sizes outside the standard scale (16, 20, 24, 32, 40dp) +- Skip interaction states (hover, active, disabled) +- Ignore accessibility requirements (contrast ratios, touch targets) + +### ALWAYS: +- Use `UiColors` for ALL color references +- Use `UiTypography` scale for all text styling +- Follow the 8pt grid for spacing (8, 16, 24, 32, 40, 48, 56, 64) +- Ensure touch targets >= 48x48dp on mobile +- Verify color contrast meets WCAG AA (4.5:1 for text, 3:1 for UI components) +- Design for both light and dark themes +- Document which design token maps to each visual element +- Include edge case designs (empty, loading, error states) +- Provide complete developer handoff notes + +## Design Tokens Reference + +### Colors +| Purpose | Token | +|---------|-------| +| Background | `UiColors.background` | +| Surface | `UiColors.surface` | +| Primary actions | `UiColors.primary` | +| Text on background | `UiColors.onBackground` | +| Text on surface | `UiColors.onSurface` | +| Secondary text | `UiColors.onSurfaceVariant` | +| Success feedback | `UiColors.success` | +| Error feedback | `UiColors.error` | +| Warning feedback | `UiColors.warning` | + +### Typography (hierarchy: display > headline > title > body > label) +| Usage | Token | +|-------|-------| +| Screen titles | `UiTypography.headlineLarge` | +| Section headers | `UiTypography.titleMedium` | +| Body text | `UiTypography.bodyLarge` | +| Labels | `UiTypography.labelMedium` | +| Button text | `UiTypography.labelLarge` | + +### Spacing +| Usage | Token | Value | +|-------|-------|-------| +| Screen padding | `UiConstants.paddingLarge` | 24dp | +| Card padding | `UiConstants.paddingMedium` | 16dp | +| Item spacing | `UiConstants.paddingSmall` | 8dp | +| Button corners | `UiConstants.radiusMedium` | 12dp | + +### Icons +- Source: `UiIcons.*` exclusively +- Standard sizes: 16, 20, 24, 32, 40dp + +## Workflows + +### Workflow 1: New Feature Design + +1. **Requirements Analysis** + - Read and internalize requirements + - Identify target personas (staff / client / business) + - List key user actions and goals + - Identify data to display and data relationships + +2. **Information Architecture** + - Define screen structure and hierarchy + - Plan navigation flow between screens + - Identify primary and secondary actions per screen + - Map data flow through the experience + +3. **Design Token Selection** + - For each UI element, select the exact color, typography, spacing, and icon tokens + - Document selections in a token mapping table + +4. **Create Design** + - Build mockups covering all screens + - Design all states: default, hover, active, disabled, error + - Design edge cases: empty states, loading states, error recovery + - Create both light and dark theme versions + - Design for mobile (375dp) and tablet (600dp+) breakpoints + +5. **Component Specifications** + - Document each component with exact design tokens, dimensions, and behavior + - Specify animation/transition behavior where applicable + - Note reusable vs. custom components + +6. **Developer Handoff** + - Provide: design link, complete token list, implementation notes + - Include: responsive behavior rules, accessibility annotations + - Format as a structured handoff document + +### Workflow 2: POC Design Compliance Review + +1. **Analyze POC** — Review screenshots and/or code to identify all colors, typography, spacing, and icons used +2. **Map to Design System** — Create a mapping table: `POC value → correct design system token` +3. **Generate Compliance Report** — Calculate compliance percentage per category, list all required changes, prioritize fixes (critical/high/medium/low) +4. **Create Compliant Version** — Redesign non-compliant elements using correct tokens +5. **Handoff** — Share corrected design and compliance report + +### Workflow 3: Design System Audit + +Run these grep patterns to find violations: +```bash +# Hardcoded colors +grep -r "Color(0x" apps/mobile/apps/*/lib/ + +# Custom TextStyles +grep -r "TextStyle(" apps/mobile/apps/*/lib/ + +# Hardcoded spacing +grep -r -E "EdgeInsets\.(all|symmetric|only)\([0-9]+" apps/mobile/apps/*/lib/ +``` + +Generate a violation report including: file locations, violation type, severity, and a prioritized remediation plan. + +## Design Quality Checklist + +Before finalizing any design, verify ALL of the following: +- [ ] All colors reference `UiColors` tokens +- [ ] All typography references `UiTypography` tokens +- [ ] All spacing follows `UiConstants` and 8pt grid +- [ ] All icons from `UiIcons` at standard sizes +- [ ] All interaction states designed (default, hover, active, disabled, error) +- [ ] Loading states designed +- [ ] Empty states designed +- [ ] Error states designed with recovery paths +- [ ] Touch targets >= 48x48dp +- [ ] Text color contrast >= 4.5:1 +- [ ] UI component contrast >= 3:1 +- [ ] Mobile layout (375dp) defined +- [ ] Tablet layout (600dp+) defined +- [ ] Component specifications documented with exact tokens +- [ ] Developer handoff notes complete +- [ ] Light and dark theme versions provided + +Explicitly run through this checklist and report the result before delivering any design. + +## Accessibility Requirements + +- **Touch targets**: >= 48x48dp minimum +- **Text contrast**: >= 4.5:1 ratio against background +- **UI component contrast**: >= 3:1 ratio +- **Semantic labels**: Provide meaningful labels for all interactive elements (for screen readers) +- **Focus order**: Ensure logical tab/focus order +- **Line length**: Target 45-75 characters per line for readability + +## Escalation Protocol + +Escalate to a human designer or PM when you encounter: +- Design system gaps (needed color, icon, or typography token doesn't exist) +- Accessibility requirements that conflict with brand guidelines +- Technical constraints that prevent design system compliance +- Ambiguous or conflicting business requirements +- Branding decisions outside the established design system + +Clearly state what you need and why you're escalating. + +## Developer Handoff Format + +After completing a design, hand off to the Mobile Feature Agent with this structure: + +``` +## Developer Handoff: [Feature Name] + +### Screens +- [List all screens designed] + +### Design Tokens Used +- Colors: [list all UiColors tokens] +- Typography: [list all UiTypography tokens] +- Spacing: [list all UiConstants tokens] +- Icons: [list all UiIcons used with sizes] + +### Component Specifications +[Detailed specs per component] + +### Edge Cases Designed +- Empty state: [description] +- Loading state: [description] +- Error state: [description] + +### Responsive Notes +- Mobile (375dp): [behavior] +- Tablet (600dp+): [behavior] + +### Accessibility Notes +- [Semantic labels, focus order, contrast notes] +``` + +## Agent Memory + +**Update your agent memory** as you discover design patterns, component usage, design system gaps, compliance issues, and architectural decisions in the KROW platform. This builds institutional knowledge across conversations. + +Examples of what to record: +- Recurring design system violations and their locations in the codebase +- Component patterns that have been established for specific feature types +- Design tokens that are frequently needed but missing from the system +- Accessibility patterns and solutions applied to specific UI challenges +- Screen layouts and navigation patterns established for different user personas +- Developer handoff preferences and implementation notes that proved useful +- Dark theme edge cases and solutions discovered during design work + +# Persistent Agent Memory + +You have a persistent Persistent Agent Memory directory at `/Users/achinthaisuru/Documents/GitHub/krow-workforce/.claude/agent-memory/ui-ux-design/`. Its contents persist across conversations. + +As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned. + +Guidelines: +- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise +- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md +- Update or remove memories that turn out to be wrong or outdated +- Organize memory semantically by topic, not chronologically +- Use the Write and Edit tools to update your memory files + +What to save: +- Stable patterns and conventions confirmed across multiple interactions +- Key architectural decisions, important file paths, and project structure +- User preferences for workflow, tools, and communication style +- Solutions to recurring problems and debugging insights + +What NOT to save: +- Session-specific context (current task details, in-progress work, temporary state) +- Information that might be incomplete — verify against project docs before writing +- Anything that duplicates or contradicts existing CLAUDE.md instructions +- Speculative or unverified conclusions from reading a single file + +Explicit user requests: +- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions +- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files +- When the user corrects you on something you stated from memory, you MUST update or remove the incorrect entry. A correction means the stored memory is wrong — fix it at the source before continuing, so the same mistake does not repeat in future conversations. +- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project + +## MEMORY.md + +Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time. diff --git a/.claude/skills/krow-mobile-architecture/SKILL.md b/.claude/skills/krow-mobile-architecture/SKILL.md new file mode 100644 index 00000000..2ba4d4cf --- /dev/null +++ b/.claude/skills/krow-mobile-architecture/SKILL.md @@ -0,0 +1,923 @@ +--- +name: krow-mobile-architecture +description: KROW mobile app Clean Architecture implementation including package structure, dependency rules, feature isolation, BLoC lifecycle management, session handling, and Data Connect connectors pattern. Use this when architecting new mobile features, debugging state management issues, preventing prop drilling, managing BLoC disposal, implementing session stores, or setting up connector repositories. Essential for maintaining architectural integrity across staff and client apps. +--- + +# KROW Mobile Architecture + +This skill defines the authoritative mobile architecture for the KROW platform. All code must strictly adhere to these principles to prevent architectural degradation. + +## When to Use This Skill + +- Architecting new mobile features +- Debugging state management or BLoC lifecycle issues +- Preventing prop drilling in UI code +- Managing session state and authentication +- Implementing Data Connect connector repositories +- Setting up feature modules and dependency injection +- Understanding package boundaries and dependencies +- Refactoring legacy code to Clean Architecture + +## 1. High-Level Architecture + +KROW follows **Clean Architecture** in a **Melos Monorepo**. Dependencies flow **inward** toward the Domain. + +``` +┌─────────────────────────────────────────────────────────┐ +│ Apps (Entry Points) │ +│ • apps/mobile/apps/client │ +│ • apps/mobile/apps/staff │ +│ Role: DI roots, navigation assembly, env config │ +└─────────────────┬───────────────────────────────────────┘ + │ depends on +┌─────────────────▼───────────────────────────────────────┐ +│ Features (Vertical Slices) │ +│ • apps/mobile/packages/features/client/* │ +│ • apps/mobile/packages/features/staff/* │ +│ Role: Pages, BLoCs, Use Cases, Feature Repositories │ +└─────┬───────────────────────────────────────┬───────────┘ + │ depends on │ depends on +┌─────▼────────────────┐ ┌───────▼───────────┐ +│ Design System │ │ Core Localization│ +│ • UI components │ │ • LocaleBloc │ +│ • Theme/colors │ │ • Translations │ +│ • Typography │ │ • ErrorTranslator│ +└──────────────────────┘ └───────────────────┘ + │ both depend on +┌─────────────────▼───────────────────────────────────────┐ +│ Services (Interface Adapters) │ +│ • data_connect: Backend integration, session mgmt │ +│ • core: Extensions, base classes, utilities │ +└─────────────────┬───────────────────────────────────────┘ + │ both depend on +┌─────────────────▼───────────────────────────────────────┐ +│ Domain (Stable Core) │ +│ • Entities (immutable data models) │ +│ • Failures (domain-specific errors) │ +│ • Pure Dart only, zero Flutter dependencies │ +└─────────────────────────────────────────────────────────┘ +``` + +**Critical Rule:** Dependencies point INWARD only. Domain knows nothing about the outer layers. + +## 2. Package Structure & Responsibilities + +### 2.1 Apps (`apps/mobile/apps/`) + +**Role:** Application entry points and DI roots + +**Responsibilities:** +- Initialize Flutter Modular +- Assemble features into navigation tree +- Inject concrete implementations (from `data_connect`) into features +- Configure environment-specific settings (dev/stage/prod) +- Initialize session management + +**Structure:** +``` +apps/mobile/apps/staff/ +├── lib/ +│ ├── main.dart # Entry point, session initialization +│ ├── app_module.dart # Root module, imports features +│ ├── app_widget.dart # MaterialApp setup +│ └── src/ +│ ├── navigation/ # Typed navigators +│ └── widgets/ # SessionListener wrapper +└── pubspec.yaml +``` + +**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:** +``` +features/staff/profile/ +├── lib/ +│ ├── src/ +│ │ ├── domain/ +│ │ │ ├── repositories/ # Repository interfaces +│ │ │ │ └── profile_repository_interface.dart +│ │ │ └── usecases/ # Application logic +│ │ │ └── get_profile_usecase.dart +│ │ ├── data/ +│ │ │ └── repositories_impl/ # Repository concrete classes +│ │ │ └── profile_repository_impl.dart +│ │ └── presentation/ +│ │ ├── blocs/ # State management +│ │ │ └── profile_cubit.dart +│ │ ├── pages/ # Screens (StatelessWidget preferred) +│ │ │ └── profile_page.dart +│ │ └── widgets/ # Reusable UI components +│ │ └── profile_header.dart +│ └── profile_feature.dart # Barrel file (public API only) +└── pubspec.yaml +``` + +**Key Principles:** +- **Presentation:** UI Pages and Widgets, BLoCs/Cubits for state +- **Application:** Use Cases (business logic orchestration) +- **Data:** Repository implementations (backend integration) +- **Pages as StatelessWidget:** Move state to BLoCs for better performance and testability + +**RESTRICTION:** Features MUST NOT import other features. Communication happens via: +- Shared domain entities +- Session stores (`StaffSessionStore`, `ClientSessionStore`) +- Navigation via Modular +- Data Connect connector repositories + +### 2.3 Domain (`apps/mobile/packages/domain`) + +**Role:** The stable, pure heart of the system + +**Responsibilities:** +- Define **Entities** (immutable data models using Data Classes or Freezed) +- Define **Failures** (domain-specific error types) + +**Structure:** +``` +domain/ +├── lib/ +│ └── src/ +│ ├── entities/ +│ │ ├── user.dart +│ │ ├── staff.dart +│ │ └── shift.dart +│ └── failures/ +│ ├── failure.dart # Base class +│ ├── auth_failure.dart +│ └── network_failure.dart +└── pubspec.yaml +``` + +**Example Entity:** +```dart +import 'package:equatable/equatable.dart'; + +class Staff extends Equatable { + final String id; + final String name; + final String email; + final StaffStatus status; + + const Staff({ + required this.id, + required this.name, + required this.email, + required this.status, + }); + + @override + List get props => [id, name, email, status]; +} +``` + +**RESTRICTION:** +- NO Flutter dependencies (no `import 'package:flutter/material.dart'`) +- NO `json_annotation` or serialization code +- Only `equatable` for value equality +- Pure Dart only + +### 2.4 Data Connect (`apps/mobile/packages/data_connect`) + +**Role:** Interface Adapter for Backend Access + +**Responsibilities:** +- Centralized connector repositories (see Data Connect Connectors Pattern section) +- Implement Firebase Data Connect service layer +- Map Domain Entities ↔ Data Connect generated code +- Handle Firebase exceptions → domain failures +- Provide `DataConnectService` with session management + +**Structure:** +``` +data_connect/ +├── lib/ +│ ├── src/ +│ │ ├── services/ +│ │ │ ├── data_connect_service.dart # Core service +│ │ │ └── mixins/ +│ │ │ └── session_handler_mixin.dart +│ │ ├── connectors/ # Connector pattern (see below) +│ │ │ ├── staff/ +│ │ │ │ ├── domain/ +│ │ │ │ │ ├── repositories/ +│ │ │ │ │ │ └── staff_connector_repository.dart +│ │ │ │ │ └── usecases/ +│ │ │ │ │ └── get_profile_completion_usecase.dart +│ │ │ │ └── data/ +│ │ │ │ └── repositories/ +│ │ │ │ └── staff_connector_repository_impl.dart +│ │ │ ├── order/ +│ │ │ └── shifts/ +│ │ └── session/ +│ │ ├── staff_session_store.dart +│ │ └── client_session_store.dart +│ └── krow_data_connect.dart # Exports +└── pubspec.yaml +``` + +**RESTRICTION:** +- NO feature-specific logic +- Connectors are domain-neutral and reusable +- All queries follow Clean Architecture (domain interfaces → data implementations) + +### 2.5 Design System (`apps/mobile/packages/design_system`) + +**Role:** Visual language and component library + +**Responsibilities:** +- Theme definitions (`UiColors`, `UiTypography`) +- UI constants (`spacingL`, `radiusM`, etc.) +- Shared widgets (if reused across multiple features) +- Assets (icons, images, fonts) + +**Structure:** +``` +design_system/ +├── lib/ +│ └── src/ +│ ├── ui_colors.dart +│ ├── ui_typography.dart +│ ├── ui_icons.dart +│ ├── ui_constants.dart +│ ├── ui_theme.dart # ThemeData factory +│ └── widgets/ # Shared UI components +│ └── custom_button.dart +└── assets/ + ├── icons/ + └── images/ +``` + +**RESTRICTION:** +- Dumb widgets ONLY (no state management) +- NO business logic +- Colors and typography are IMMUTABLE (no feature can override) + +### 2.6 Core Localization (`apps/mobile/packages/core_localization`) + +**Role:** Centralized i18n management + +**Responsibilities:** +- Define all user-facing strings in `l10n/` +- Provide `LocaleBloc` for locale state management +- Export `TranslationProvider` for `context.strings` access +- Map domain failures to localized error messages via `ErrorTranslator` + +**String Definition:** +- Strings are defined in `packages/core_localization/lib/src/l10n/en.i18n.json` (English) and `es.i18n.json` (Spanish) +- Both files MUST be updated together when adding/modifying strings +- Generated output: `strings.g.dart`, `strings_en.g.dart`, `strings_es.g.dart` +- Regenerate with: `cd packages/core_localization && dart run slang` + +**Feature Integration:** +```dart +// ✅ CORRECT: Access via Slang's global `t` accessor +import 'package:core_localization/core_localization.dart'; + +Text(t.client_create_order.review.invalid_arguments) +Text(t.errors.order.creation_failed) + +// ❌ FORBIDDEN: Hardcoded user-facing strings +Text('Invalid review arguments') // Must use localized key +Text('Order created!') // Must use localized key +``` + +**RESTRICTION:** ALL user-facing strings in the presentation layer (Text widgets, SnackBars, AppBar titles, hints, labels, error messages, dialogs) MUST use localized keys via `t.
.`. No hardcoded English or Spanish strings. + +**BLoC Error Flow:** +```dart +// BLoCs emit domain failures (not strings) +emit(AuthError(InvalidCredentialsFailure())); + +// UI translates failures to localized messages +final message = ErrorTranslator.translate(failure, context.strings); +``` + +**App Setup:** +```dart +// App imports LocalizationModule +class AppModule extends Module { + @override + List get imports => [LocalizationModule()]; +} + +// Wrap app with providers +BlocProvider( + create: (_) => Modular.get(), + child: TranslationProvider( + child: MaterialApp.router(...), + ), +) +``` + +### 2.7 Core (`apps/mobile/packages/core`) + +**Role:** Cross-cutting concerns + +**Responsibilities:** +- Extension methods (NavigationExtensions, ListExtensions, etc.) +- Base classes (UseCase, Failure, BlocErrorHandler) +- Logger configuration +- Result types for functional error handling + +## 3. Dependency Direction Rules + +1. **Domain Independence:** `domain` knows NOTHING about outer layers + - Defines *what* needs to be done, not *how* + - Pure Dart, zero Flutter dependencies + - Stable contracts that rarely change + +2. **UI Agnosticism:** Features depend on `design_system` for UI and `domain` for logic + - Features do NOT know about Firebase or backend details + - Backend changes don't affect feature implementation + +3. **Data Isolation:** `data_connect` depends on `domain` to know interfaces + - Implements domain repository interfaces + - Maps backend models to domain entities + - Does NOT know about UI + +**Dependency Flow:** +``` +Apps → Features → Design System + → Core Localization + → Data Connect → Domain + → Core +``` + +## 4. Data Connect Service & 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 <5 minutes to expiry) +- Firebase auth state listening +- Role-based access validation +- Session state stream emissions +- 3-attempt retry with exponential backoff (1s → 2s → 4s) + +**Key Method:** +```dart +// Call once on app startup +DataConnectService.instance.initializeAuthListener( + allowedRoles: ['STAFF', 'BOTH'], // or ['CLIENT', 'BUSINESS', 'BOTH'] +); +``` + +### 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 + +**Usage:** +```dart +// main.dart +runApp( + SessionListener( // ← Critical wrapper + child: ModularApp(module: AppModule(), child: AppWidget()), + ), +); +``` + +### 4.3 Repository Pattern with Data Connect + +**Step 1:** Define interface in feature domain: +```dart +// features/staff/profile/lib/src/domain/repositories/ +abstract interface class ProfileRepositoryInterface { + Future getProfile(String id); +} +``` + +**Step 2:** Implement using `DataConnectService.run()`: +```dart +// features/staff/profile/lib/src/data/repositories_impl/ +class ProfileRepositoryImpl implements ProfileRepositoryInterface { + final DataConnectService _service = DataConnectService.instance; + + @override + Future getProfile(String id) async { + return await _service.run(() async { + final response = await _service.connector + .getStaffById(id: id) + .execute(); + return _mapToStaff(response.data.staff); + }); + } +} +``` + +**Benefits of `_service.run()`:** +- ✅ Auto validates user is authenticated +- ✅ Refreshes token if <5 min to expiry +- ✅ Executes the query +- ✅ 3-attempt retry with exponential backoff +- ✅ Maps exceptions to domain failures + +### 4.4 Session Store Pattern + +After successful auth, populate session stores: + +**Staff App:** +```dart +StaffSessionStore.instance.setSession( + StaffSession( + user: user, + staff: staff, + ownerId: ownerId, + ), +); +``` + +**Client App:** +```dart +ClientSessionStore.instance.setSession( + ClientSession( + user: user, + business: business, + ), +); +``` + +**Lazy Loading:** If session is null, fetch from backend and update: +```dart +final session = StaffSessionStore.instance.session; +if (session?.staff == null) { + final staff = await getStaffById(session!.user.uid); + StaffSessionStore.instance.setSession( + session.copyWith(staff: staff), + ); +} +``` + +## 5. Feature Isolation & Communication + +### Zero Direct Imports + +```dart +// ❌ FORBIDDEN +import 'package:staff_profile/staff_profile.dart'; // in another feature + +// ✅ ALLOWED +import 'package:krow_domain/krow_domain.dart'; // shared domain +import 'package:krow_core/krow_core.dart'; // shared utilities +import 'package:design_system/design_system.dart'; // shared UI +``` + +### Navigation: Typed Navigators with Safe Extensions + +**Safe Navigation Extensions** (from `core` package): +```dart +extension NavigationExtensions on IModularNavigator { + /// Safely navigate with fallback to home + Future safeNavigate(String route) async { + try { + await navigate(route); + } catch (e) { + await navigate('/home'); // Fallback + } + } + + /// Safely push with fallback to home + Future safePush(String route) async { + try { + return await pushNamed(route); + } catch (e) { + await navigate('/home'); + return null; + } + } + + /// Safely pop with guard against empty stack + void popSafe() { + if (canPop()) { + pop(); + } else { + navigate('/home'); + } + } +} +``` + +**Typed Navigators:** +```dart +// apps/mobile/apps/staff/lib/src/navigation/staff_navigator.dart +extension StaffNavigator on IModularNavigator { + Future toStaffHome() => safeNavigate(StaffPaths.home); + + Future toShiftDetails(String shiftId) => + safePush('${StaffPaths.shifts}/$shiftId'); + + Future toProfileEdit() => safePush(StaffPaths.profileEdit); +} +``` + +**Usage in Features:** +```dart +// ✅ CORRECT +Modular.to.toStaffHome(); +Modular.to.toShiftDetails(shiftId: '123'); +Modular.to.popSafe(); + +// ❌ AVOID +Modular.to.navigate('/home'); // No safety +Navigator.push(...); // No Modular integration +``` + +### Data Sharing Patterns + +Features don't share state directly. Use: + +1. **Domain Repositories:** Centralized data sources +2. **Session Stores:** `StaffSessionStore`, `ClientSessionStore` for app-wide context +3. **Event Streams:** If needed, via `DataConnectService` streams +4. **Navigation Arguments:** Pass IDs, not full objects + +## 6. App-Specific Session Management + +### Staff App + +```dart +// main.dart +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + DataConnectService.instance.initializeAuthListener( + allowedRoles: ['STAFF', 'BOTH'], + ); + + runApp( + SessionListener( + child: ModularApp(module: StaffAppModule(), child: StaffApp()), + ), + ); +} +``` + +**Session Store:** `StaffSessionStore` +- Fields: `user`, `staff`, `ownerId` +- Lazy load: `getStaffById()` if staff is null + +**Navigation:** +- Authenticated → `Modular.to.toStaffHome()` +- Unauthenticated → `Modular.to.toInitialPage()` + +### Client App + +```dart +// main.dart +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + DataConnectService.instance.initializeAuthListener( + allowedRoles: ['CLIENT', 'BUSINESS', 'BOTH'], + ); + + runApp( + SessionListener( + child: ModularApp(module: ClientAppModule(), child: ClientApp()), + ), + ); +} +``` + +**Session Store:** `ClientSessionStore` +- Fields: `user`, `business` +- Lazy load: `getBusinessById()` if business is null + +**Navigation:** +- Authenticated → `Modular.to.toClientHome()` +- Unauthenticated → `Modular.to.toInitialPage()` + +## 7. Data Connect Connectors Pattern + +**Problem:** Without connectors, each feature duplicates backend queries. + +**Solution:** Centralize all backend queries in `data_connect/connectors/`. + +### Structure + +Mirror backend connector structure: + +``` +data_connect/lib/src/connectors/ +├── staff/ +│ ├── domain/ +│ │ ├── repositories/ +│ │ │ └── staff_connector_repository.dart # Interface +│ │ └── usecases/ +│ │ └── get_profile_completion_usecase.dart +│ └── data/ +│ └── repositories/ +│ └── staff_connector_repository_impl.dart # Implementation +├── order/ +├── shifts/ +└── user/ +``` + +**Maps to backend:** +``` +backend/dataconnect/connector/ +├── staff/ +├── order/ +├── shifts/ +└── user/ +``` + +### Clean Architecture in Connectors + +**Domain Interface:** +```dart +// staff_connector_repository.dart +abstract interface class StaffConnectorRepository { + Future getProfileCompletion(); + Future getStaffById(String id); +} +``` + +**Use Case:** +```dart +// get_profile_completion_usecase.dart +class GetProfileCompletionUseCase { + final StaffConnectorRepository _repository; + + GetProfileCompletionUseCase({required StaffConnectorRepository repository}) + : _repository = repository; + + Future call() => _repository.getProfileCompletion(); +} +``` + +**Data Implementation:** +```dart +// staff_connector_repository_impl.dart +class StaffConnectorRepositoryImpl implements StaffConnectorRepository { + final DataConnectService _service; + + @override + Future getProfileCompletion() async { + return _service.run(() async { + final staffId = await _service.getStaffId(); + final response = await _service.connector + .getStaffProfileCompletion(id: staffId) + .execute(); + + return _isProfileComplete(response); + }); + } +} +``` + +### Feature Integration + +**Step 1:** Feature registers connector repository: +```dart +// staff_main_module.dart +class StaffMainModule extends Module { + @override + void binds(Injector i) { + i.addLazySingleton( + StaffConnectorRepositoryImpl.new, + ); + + i.addLazySingleton( + () => GetProfileCompletionUseCase( + repository: i.get(), + ), + ); + + i.addLazySingleton( + () => StaffMainCubit( + getProfileCompletionUsecase: i.get(), + ), + ); + } +} +``` + +**Step 2:** BLoC uses it: +```dart +class StaffMainCubit extends Cubit { + final GetProfileCompletionUseCase _getProfileCompletionUsecase; + + Future loadProfileCompletion() async { + final isComplete = await _getProfileCompletionUsecase(); + emit(state.copyWith(isProfileComplete: isComplete)); + } +} +``` + +### Benefits + +✅ **No Duplication** - Query implemented once, used by many features +✅ **Single Source of Truth** - Backend change → update one place +✅ **Reusability** - Any feature can use any connector +✅ **Testability** - Mock connector repo to test features +✅ **Scalability** - Easy to add connectors as backend grows + +## 8. Avoiding Prop Drilling: Direct BLoC Access + +### The Problem + +Passing data through intermediate widgets creates maintenance burden: + +```dart +// ❌ BAD: Prop drilling +ProfilePage(status: status) + → ProfileHeader(status: status) + → ProfileLevelBadge(status: status) // Only widget that needs it +``` + +### The Solution: BlocBuilder in Leaf Widgets + +```dart +// ✅ GOOD: Direct BLoC access +class ProfileLevelBadge extends StatelessWidget { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.profile == null) return const SizedBox.shrink(); + + final level = _mapStatusToLevel(state.profile!.status); + return LevelBadgeUI(level: level); + }, + ); + } +} +``` + +### Guidelines + +1. **Leaf Widgets Access BLoC:** Widgets needing specific data should use `BlocBuilder` +2. **Container Widgets Stay Simple:** Parent widgets only manage layout +3. **No Unnecessary Props:** Don't pass data to intermediate widgets +4. **Single Responsibility:** Each widget has one reason to exist + +**Decision Tree:** +``` +Does this widget need data? +├─ YES, leaf widget → Use BlocBuilder +├─ YES, container → Use BlocBuilder in child +└─ NO → Don't add prop +``` + +## 9. BLoC Lifecycle & State Emission Safety + +### The Problem: StateError After Dispose + +When async operations complete after BLoC is closed: +``` +StateError: Cannot emit new states after calling close +``` + +**Root Causes:** +1. Transient BLoCs created with `BlocProvider(create:)` → disposed prematurely +2. Multiple BlocProviders disposing same singleton +3. User navigates away during async operation + +### The Solution: Singleton BLoCs + Safe Emit + +#### Step 1: Register as Singleton + +```dart +// ✅ GOOD: Singleton registration +i.addLazySingleton( + () => ProfileCubit(useCase1, useCase2), +); + +// ❌ BAD: Creates new instance each time +i.add(ProfileCubit.new); +``` + +#### Step 2: Use BlocProvider.value() + +```dart +// ✅ GOOD: Reuse singleton +final cubit = Modular.get(); +BlocProvider.value( + value: cubit, + child: MyWidget(), +) + +// ❌ BAD: Creates duplicate +BlocProvider( + create: (_) => Modular.get(), + child: MyWidget(), +) +``` + +#### Step 3: Safe Emit with BlocErrorHandler + +**Location:** `apps/mobile/packages/core/lib/src/presentation/mixins/bloc_error_handler.dart` + +```dart +mixin BlocErrorHandler on Cubit { + void _safeEmit(void Function(S) emit, S state) { + try { + emit(state); + } on StateError catch (e) { + developer.log( + 'Could not emit state: ${e.message}. Bloc may have been disposed.', + name: runtimeType.toString(), + ); + } + } +} +``` + +**Usage:** +```dart +class ProfileCubit extends Cubit with BlocErrorHandler { + Future loadProfile() async { + emit(state.copyWith(status: ProfileStatus.loading)); + + await handleError( + emit: emit, + action: () async { + final profile = await getProfile(); + emit(state.copyWith(status: ProfileStatus.loaded, profile: profile)); + // ✅ Safe even if BLoC disposed + }, + onError: (errorKey) => state.copyWith(status: ProfileStatus.error), + ); + } +} +``` + +### Pattern Summary + +| Pattern | When to Use | Risk | +|---------|------------|------| +| Singleton + BlocProvider.value() | Long-lived features | Low | +| Transient + BlocProvider(create:) | Temporary widgets | Medium | +| Direct BlocBuilder | Leaf widgets | Low | + +## 10. Anti-Patterns to Avoid + +❌ **Feature imports feature** +```dart +import 'package:staff_profile/staff_profile.dart'; // in another feature +``` + +❌ **Business logic in BLoC** +```dart +on((event, emit) { + if (event.email.isEmpty) { // ← Use case responsibility + emit(AuthError('Email required')); + } +}); +``` + +❌ **Direct Data Connect in features** +```dart +final response = await FirebaseDataConnect.instance.query(); // ← Use repository +``` + +❌ **Global state variables** +```dart +User? currentUser; // ← Use SessionStore +``` + +❌ **Direct Navigator.push** +```dart +Navigator.push(context, MaterialPageRoute(...)); // ← Use Modular +``` + +❌ **Hardcoded navigation** +```dart +Modular.to.navigate('/profile'); // ← Use safe extensions +``` + +❌ **Hardcoded user-facing strings** +```dart +Text('Order created successfully!'); // ← Use t.section.key from core_localization +``` + +## Summary + +The architecture enforces: +- **Clean Architecture** with strict layer boundaries +- **Feature Isolation** via zero cross-feature imports +- **Session Management** via DataConnectService and SessionListener +- **Connector Pattern** for reusable backend queries +- **BLoC Lifecycle** safety with singletons and safe emit +- **Navigation Safety** with typed navigators and fallbacks + +When implementing features: +1. Follow package structure strictly +2. Use connector repositories for backend access +3. Register BLoCs as singletons with `.value()` +4. Use safe navigation extensions +5. Avoid prop drilling with direct BLoC access +6. Keep domain pure and stable + +Architecture is not negotiable. When in doubt, refer to existing well-structured features or ask for clarification. diff --git a/.claude/skills/krow-mobile-design-system/SKILL.md b/.claude/skills/krow-mobile-design-system/SKILL.md new file mode 100644 index 00000000..2f6d6a40 --- /dev/null +++ b/.claude/skills/krow-mobile-design-system/SKILL.md @@ -0,0 +1,717 @@ +--- +name: krow-mobile-design-system +description: KROW mobile design system usage rules covering colors, typography, icons, spacing, and UI component patterns. Use this when implementing UI in KROW mobile features, matching POC designs to production, creating themed widgets, enforcing visual consistency, or reviewing UI code compliance. Prevents hardcoded values and ensures brand consistency across staff and client apps. Critical for maintaining immutable design tokens. +--- + +# KROW Mobile Design System Usage + +This skill defines mandatory standards for UI implementation using the shared `apps/mobile/packages/design_system`. All UI must consume design system tokens exclusively. + +## When to Use This Skill + +- Implementing any UI in mobile features +- Migrating POC/prototype designs to production +- Creating new themed widgets or components +- Reviewing UI code for design system compliance +- Matching colors and typography from designs +- Adding icons, spacing, or layout elements +- Setting up theme configuration in apps +- Refactoring UI code with hardcoded values + +## Core Principle + +**Design tokens (colors, typography, spacing) are IMMUTABLE and defined centrally.** + +Features consume tokens but NEVER modify them. The design system maintains visual coherence across all apps. + +## 1. Design System Ownership + +### Centralized Authority + +- `apps/mobile/packages/design_system` owns: + - All brand assets + - Colors and semantic color mappings + - Typography and font configurations + - Core UI components + - Icons and images + - Spacing, radius, elevation constants + +### No Local Overrides + +**✅ CORRECT:** +```dart +// Feature uses design system +import 'package:design_system/design_system.dart'; + +Container( + color: UiColors.background, + padding: EdgeInsets.all(UiConstants.spacingL), + child: Text( + 'Hello', + style: UiTypography.display1m, + ), +) +``` + +**❌ FORBIDDEN:** +```dart +// ❌ Custom colors in feature +const myBlue = Color(0xFF1A2234); + +// ❌ Custom text styles in feature +const myStyle = TextStyle(fontSize: 24, fontWeight: FontWeight.bold); + +// ❌ Theme overrides in feature +Theme( + data: ThemeData(primaryColor: Colors.blue), + child: MyWidget(), +) +``` + +### Extension Policy + +If a required style is missing: +1. **FIRST:** Add it to `design_system` following existing patterns +2. **THEN:** Use it in your feature + +**DO NOT** create temporary workarounds with hardcoded values. + +## 2. Package Structure + +``` +apps/mobile/packages/design_system/ +├── lib/ +│ ├── src/ +│ │ ├── ui_colors.dart # Color tokens +│ │ ├── ui_typography.dart # Text styles +│ │ ├── ui_icons.dart # Icon exports +│ │ ├── ui_constants.dart # Spacing, radius, elevation +│ │ ├── ui_theme.dart # ThemeData factory +│ │ └── widgets/ # Shared UI components +│ │ ├── custom_button.dart +│ │ └── custom_app_bar.dart +│ └── design_system.dart # Public exports +├── assets/ +│ ├── icons/ +│ ├── images/ +│ └── fonts/ +└── pubspec.yaml +``` + +## 3. Colors Usage Rules + +### Strict Protocol + +**✅ DO:** +```dart +// Use UiColors for all color needs +Container(color: UiColors.background) +Text('Hello', style: TextStyle(color: UiColors.foreground)) +Icon(Icons.home, color: UiColors.primary) +``` + +**❌ DON'T:** +```dart +// ❌ Hardcoded hex colors +Container(color: Color(0xFF1A2234)) + +// ❌ Material color constants +Container(color: Colors.blue) + +// ❌ Opacity on hardcoded colors +Container(color: Color(0xFF1A2234).withOpacity(0.5)) +``` + +### Available Color Categories + +**Brand Colors:** +- `UiColors.primary` - Main brand color +- `UiColors.secondary` - Secondary brand color +- `UiColors.accent` - Accent highlights + +**Semantic Colors:** +- `UiColors.background` - Page background +- `UiColors.foreground` - Primary text color +- `UiColors.card` - Card/container background +- `UiColors.border` - Border colors +- `UiColors.mutedForeground` - Secondary text + +**Status Colors:** +- `UiColors.success` - Success states +- `UiColors.warning` - Warning states +- `UiColors.error` - Error states +- `UiColors.info` - Information states + +### Color Matching from POCs + +When migrating POC designs: + +1. **Find closest match** in `UiColors` +2. **Use existing color** even if slightly different +3. **DO NOT add new colors** without design team approval + +**Example Process:** +```dart +// POC has: Color(0xFF2C3E50) +// Find closest: UiColors.background or UiColors.card +// Use: UiColors.card + +// POC has: Color(0xFF27AE60) +// Find closest: UiColors.success +// Use: UiColors.success +``` + +### Theme Access + +Colors can also be accessed via theme: +```dart +// Both are valid: +Container(color: UiColors.primary) +Container(color: Theme.of(context).colorScheme.primary) +``` + +## 4. Typography Usage Rules + +### Strict Protocol + +**✅ DO:** +```dart +// Use UiTypography for all text +Text('Title', style: UiTypography.display1m) +Text('Body', style: UiTypography.body1r) +Text('Label', style: UiTypography.caption1m) +``` + +**❌ DON'T:** +```dart +// ❌ Custom TextStyle +Text('Title', style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, +)) + +// ❌ Manual font configuration +Text('Body', style: TextStyle( + fontFamily: 'Inter', + fontSize: 16, +)) + +// ❌ Modifying existing styles inline +Text('Title', style: UiTypography.display1m.copyWith( + fontSize: 28, // ← Don't override size +)) +``` + +### Available Typography Styles + +**Display Styles (Large Headers):** +- `UiTypography.display1m` - Display Medium +- `UiTypography.display1sb` - Display Semi-Bold +- `UiTypography.display1b` - Display Bold + +**Heading Styles:** +- `UiTypography.heading1m` - H1 Medium +- `UiTypography.heading1sb` - H1 Semi-Bold +- `UiTypography.heading1b` - H1 Bold +- `UiTypography.heading2m` - H2 Medium +- `UiTypography.heading2sb` - H2 Semi-Bold + +**Body Styles:** +- `UiTypography.body1r` - Body Regular +- `UiTypography.body1m` - Body Medium +- `UiTypography.body1sb` - Body Semi-Bold +- `UiTypography.body2r` - Body 2 Regular + +**Caption/Label Styles:** +- `UiTypography.caption1m` - Caption Medium +- `UiTypography.caption1sb` - Caption Semi-Bold +- `UiTypography.label1m` - Label Medium + +### Allowed Customizations + +**✅ ALLOWED (Color Only):** +```dart +// You MAY change color +Text( + 'Title', + style: UiTypography.display1m.copyWith( + color: UiColors.error, // ← OK + ), +) +``` + +**❌ FORBIDDEN (Size, Weight, Family):** +```dart +// ❌ Don't change size +Text( + 'Title', + style: UiTypography.display1m.copyWith(fontSize: 28), +) + +// ❌ Don't change weight +Text( + 'Title', + style: UiTypography.display1m.copyWith(fontWeight: FontWeight.w900), +) + +// ❌ Don't change family +Text( + 'Title', + style: UiTypography.display1m.copyWith(fontFamily: 'Roboto'), +) +``` + +### Typography Matching from POCs + +When migrating: +1. Identify text role (heading, body, caption) +2. Find closest matching style in `UiTypography` +3. Use existing style even if size/weight differs slightly + +## 5. Icons Usage Rules + +### Strict Protocol + +**✅ DO:** +```dart +// Use UiIcons +Icon(UiIcons.home) +Icon(UiIcons.profile) +Icon(UiIcons.chevronLeft) +``` + +**❌ DON'T:** +```dart +// ❌ Direct icon library imports +import 'package:lucide_icons/lucide_icons.dart'; +Icon(LucideIcons.home) + +// ❌ Font Awesome direct +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +FaIcon(FontAwesomeIcons.house) +``` + +### Why Centralize Icons? + +1. **Consistency:** Same icon for same action everywhere +2. **Branding:** Unified icon set with consistent stroke weight +3. **Swappability:** Change icon library in one place + +### Icon Libraries + +Design system uses: +- `typedef _IconLib = LucideIcons;` (primary) +- `typedef _IconLib2 = FontAwesomeIcons;` (secondary) + +**Features MUST NOT import these directly.** + +### Adding New Icons + +If icon missing: +1. Add to `ui_icons.dart`: +```dart +class UiIcons { + static const home = _IconLib.home; + static const newIcon = _IconLib.newIcon; // Add here +} +``` +2. Use in feature: +```dart +Icon(UiIcons.newIcon) +``` + +## 6. Spacing & Layout Constants + +### Strict Protocol + +**✅ DO:** +```dart +// Use UiConstants for spacing +Padding(padding: EdgeInsets.all(UiConstants.spacingL)) +SizedBox(height: UiConstants.spacingM) +Container( + padding: EdgeInsets.symmetric( + horizontal: UiConstants.spacingL, + vertical: UiConstants.spacingM, + ), +) + +// Use UiConstants for radius +Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(UiConstants.radiusM), + ), +) + +// Use UiConstants for elevation +elevation: UiConstants.elevationLow +``` + +**❌ DON'T:** +```dart +// ❌ Magic numbers +Padding(padding: EdgeInsets.all(16.0)) +SizedBox(height: 24.0) +BorderRadius.circular(8.0) +elevation: 2.0 +``` + +### Available Constants + +**Spacing:** +```dart +UiConstants.spacingXs // Extra small +UiConstants.spacingS // Small +UiConstants.spacingM // Medium +UiConstants.spacingL // Large +UiConstants.spacingXl // Extra large +UiConstants.spacing2xl // 2x Extra large +``` + +**Border Radius:** +```dart +UiConstants.radiusS // Small +UiConstants.radiusM // Medium +UiConstants.radiusL // Large +UiConstants.radiusXl // Extra large +UiConstants.radiusFull // Fully rounded +``` + +**Elevation:** +```dart +UiConstants.elevationNone +UiConstants.elevationLow +UiConstants.elevationMedium +UiConstants.elevationHigh +``` + +## 7. Smart Widgets Usage + +### When to Use + +- **Prefer standard Flutter Material widgets** styled via theme +- **Use design system widgets** for non-standard patterns +- **Create new widgets** in design system if reused >3 features + +### Navigation in Widgets + +Widgets with navigation MUST use safe methods: + +**✅ CORRECT:** +```dart +// In UiAppBar back button: +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/krow_core.dart'; + +IconButton( + icon: Icon(UiIcons.chevronLeft), + onPressed: () => Modular.to.popSafe(), // ← Safe pop +) +``` + +**❌ FORBIDDEN:** +```dart +// ❌ Direct Navigator +IconButton( + icon: Icon(UiIcons.chevronLeft), + onPressed: () => Navigator.pop(context), +) + +// ❌ Unsafe Modular +IconButton( + icon: Icon(UiIcons.chevronLeft), + onPressed: () => Modular.to.pop(), // Can crash +) +``` + +### Composition Over Inheritance + +**✅ CORRECT:** +```dart +// Compose standard widgets +Container( + padding: EdgeInsets.all(UiConstants.spacingL), + decoration: BoxDecoration( + color: UiColors.card, + borderRadius: BorderRadius.circular(UiConstants.radiusM), + ), + child: Column( + children: [ + Text('Title', style: UiTypography.heading1sb), + SizedBox(height: UiConstants.spacingM), + Text('Body', style: UiTypography.body1r), + ], + ), +) +``` + +**❌ AVOID:** +```dart +// ❌ Deep custom widget hierarchies +class CustomCard extends StatelessWidget { + // Complex custom implementation +} +``` + +## 8. Theme Configuration + +### App Setup + +Apps initialize theme ONCE in root MaterialApp: + +**✅ CORRECT:** +```dart +// apps/mobile/apps/staff/lib/app_widget.dart +import 'package:design_system/design_system.dart'; + +class StaffApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MaterialApp.router( + theme: StaffTheme.light, // ← Design system theme + darkTheme: StaffTheme.dark, // ← Optional dark mode + themeMode: ThemeMode.system, + // ... + ); + } +} +``` + +**❌ FORBIDDEN:** +```dart +// ❌ Custom theme in app +MaterialApp.router( + theme: ThemeData( + primaryColor: Colors.blue, // ← NO! + ), +) + +// ❌ Theme override in feature +Theme( + data: ThemeData(...), + child: MyFeatureWidget(), +) +``` + +### Accessing Theme + +**Both methods valid:** +```dart +// Method 1: Direct design system import +import 'package:design_system/design_system.dart'; +Text('Hello', style: UiTypography.body1r) + +// Method 2: Via theme context +Text('Hello', style: Theme.of(context).textTheme.bodyMedium) +``` + +**Prefer Method 1** for explicit type safety. + +## 9. POC → Production Workflow + +### Step 1: Implement Structure (POC Matching) + +Implement UI layout exactly matching POC: +```dart +// Temporary: Match POC visually +Container( + color: Color(0xFF1A2234), // ← POC color + padding: EdgeInsets.all(16.0), // ← POC spacing + child: Text( + 'Title', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), // ← POC style + ), +) +``` + +**Purpose:** Ensure visual parity with POC before refactoring. + +### Step 2: Architecture Refactor + +Move to Clean Architecture: +- Extract business logic to use cases +- Move state management to BLoCs +- Implement repository pattern +- Use dependency injection + +### Step 3: Design System Integration + +Replace hardcoded values: +```dart +// Production: Design system tokens +Container( + color: UiColors.background, // ← Found closest match + padding: EdgeInsets.all(UiConstants.spacingL), // ← Used constant + child: Text( + 'Title', + style: UiTypography.heading1sb, // ← Matched typography + ), +) +``` + +**Color Matching:** +- POC `#1A2234` → `UiColors.background` +- POC `#3498DB` → `UiColors.primary` +- POC `#27AE60` → `UiColors.success` + +**Typography Matching:** +- POC `24px bold` → `UiTypography.heading1sb` +- POC `16px regular` → `UiTypography.body1r` +- POC `14px medium` → `UiTypography.caption1m` + +**Spacing Matching:** +- POC `16px` → `UiConstants.spacingL` +- POC `8px` → `UiConstants.spacingM` +- POC `4px` → `UiConstants.spacingS` + +## 10. Anti-Patterns & Common Mistakes + +### ❌ Magic Numbers +```dart +// BAD +EdgeInsets.all(12.0) +SizedBox(height: 24.0) +BorderRadius.circular(8.0) + +// GOOD +EdgeInsets.all(UiConstants.spacingM) +SizedBox(height: UiConstants.spacingL) +BorderRadius.circular(UiConstants.radiusM) +``` + +### ❌ Local Themes +```dart +// BAD +Theme( + data: ThemeData(primaryColor: Colors.blue), + child: MyWidget(), +) + +// GOOD +// Use global theme defined in app +``` + +### ❌ Hex Hunting +```dart +// BAD: Copy-paste from Figma +Container(color: Color(0xFF3498DB)) + +// GOOD: Find matching design system color +Container(color: UiColors.primary) +``` + +### ❌ Direct Icon Library +```dart +// BAD +import 'package:lucide_icons/lucide_icons.dart'; +Icon(LucideIcons.home) + +// GOOD +Icon(UiIcons.home) +``` + +### ❌ Custom Text Styles +```dart +// BAD +Text('Title', style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + fontFamily: 'Inter', +)) + +// GOOD +Text('Title', style: UiTypography.heading1sb) +``` + +## 11. Design System Review Checklist + +Before merging UI code: + +### ✅ Design System Compliance +- [ ] No hardcoded `Color(...)` or `0xFF...` hex values +- [ ] No custom `TextStyle(...)` definitions +- [ ] All spacing uses `UiConstants.spacing*` +- [ ] All radius uses `UiConstants.radius*` +- [ ] All elevation uses `UiConstants.elevation*` +- [ ] All icons from `UiIcons`, not direct library imports +- [ ] Theme consumed from design system, no local overrides +- [ ] Layout matches POC intent using design system primitives + +### ✅ Architecture Compliance +- [ ] No business logic in widgets +- [ ] State managed by BLoCs +- [ ] Navigation uses Modular safe extensions +- [ ] Localization used for all text (no hardcoded strings) +- [ ] No direct Data Connect queries in widgets + +### ✅ Code Quality +- [ ] Widget build methods concise (<50 lines) +- [ ] Complex widgets extracted to separate files +- [ ] Meaningful widget names +- [ ] Doc comments on reusable widgets + +## 12. When to Extend Design System + +### Add New Color +**When:** New brand color approved by design team + +**Process:** +1. Add to `ui_colors.dart`: +```dart +class UiColors { + static const myNewColor = Color(0xFF123456); +} +``` +2. Update theme if needed +3. Use in features + +### Add New Typography Style +**When:** New text style pattern emerges across multiple features + +**Process:** +1. Add to `ui_typography.dart`: +```dart +class UiTypography { + static const myNewStyle = TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + fontFamily: _fontFamily, + ); +} +``` +2. Use in features + +### Add Shared Widget +**When:** Widget reused in 3+ features + +**Process:** +1. Create in `lib/src/widgets/`: +```dart +// my_widget.dart +class MyWidget extends StatelessWidget { + // Implementation using design system tokens +} +``` +2. Export from `design_system.dart` +3. Use across features + +## Summary + +**Core Rules:** +1. **All colors from `UiColors`** - Zero hex codes in features +2. **All typography from `UiTypography`** - Zero custom TextStyle +3. **All spacing/radius/elevation from `UiConstants`** - Zero magic numbers +4. **All icons from `UiIcons`** - Zero direct library imports +5. **Theme defined once** in app entry point +6. **POC → Production** requires design system integration step + +**The Golden Rule:** Design system is immutable. Features adapt to the system, not the other way around. + +When implementing UI: +1. Import `package:design_system/design_system.dart` +2. Use design system tokens exclusively +3. Match POC intent with available tokens +4. Request new tokens only when truly necessary +5. Never create temporary hardcoded workarounds + +Visual consistency is non-negotiable. Every pixel must come from the design system. diff --git a/.claude/skills/krow-mobile-development-rules/SKILL.md b/.claude/skills/krow-mobile-development-rules/SKILL.md new file mode 100644 index 00000000..4f4adc0f --- /dev/null +++ b/.claude/skills/krow-mobile-development-rules/SKILL.md @@ -0,0 +1,646 @@ +--- +name: krow-mobile-development-rules +description: Enforce KROW mobile app development standards including file structure, naming conventions, logic placement boundaries, localization, Data Connect integration, and prototype migration rules. Use this skill whenever working on KROW Flutter mobile features, creating new packages, implementing BLoCs, integrating with backend, or migrating from prototypes. Critical for maintaining clean architecture and preventing architectural degradation. +--- + +# KROW Mobile Development Rules + +These rules are **NON-NEGOTIABLE** enforcement guidelines for the KROW mobile application. They prevent architectural degradation and ensure consistency across the codebase. + +## When to Use This Skill + +- Creating new mobile features or packages +- Implementing BLoCs, Use Cases, or Repositories +- Integrating with Firebase Data Connect backend +- Migrating code from prototypes +- Reviewing mobile code for compliance +- Setting up new feature modules +- Handling user sessions and authentication +- Implementing navigation flows + +## 1. File Creation & Package Structure + +### Feature-First Packaging + +**✅ DO:** +- Create new features as independent packages: + ``` + apps/mobile/packages/features/// + ├── lib/ + │ ├── src/ + │ │ ├── domain/ + │ │ │ ├── repositories/ + │ │ │ └── usecases/ + │ │ ├── data/ + │ │ │ └── repositories_impl/ + │ │ └── presentation/ + │ │ ├── blocs/ + │ │ ├── pages/ + │ │ └── widgets/ + │ └── .dart # Barrel file + └── pubspec.yaml + ``` + +**❌ DON'T:** +- Add features to `apps/mobile/packages/core` directly +- Create files in app directories (`apps/mobile/apps/client/` or `apps/mobile/apps/staff/`) +- Create cross-feature or cross-app dependencies (features must not import other features) + +### Path Conventions (Strict) + +Follow these exact paths: + +| Layer | Path Pattern | Example | +|-------|-------------|---------| +| **Entities** | `apps/mobile/packages/domain/lib/src/entities/.dart` | `user.dart`, `shift.dart` | +| **Repository Interface** | `.../features///lib/src/domain/repositories/_repository_interface.dart` | `auth_repository_interface.dart` | +| **Repository Impl** | `.../features///lib/src/data/repositories_impl/_repository_impl.dart` | `auth_repository_impl.dart` | +| **Use Cases** | `.../features///lib/src/application/_usecase.dart` | `login_usecase.dart` | +| **BLoCs** | `.../features///lib/src/presentation/blocs/_bloc.dart` | `auth_bloc.dart` | +| **Pages** | `.../features///lib/src/presentation/pages/_page.dart` | `login_page.dart` | +| **Widgets** | `.../features///lib/src/presentation/widgets/_widget.dart` | `password_field.dart` | + +### Barrel Files + +**✅ DO:** +```dart +// lib/auth_feature.dart +export 'src/presentation/pages/login_page.dart'; +export 'src/domain/repositories/auth_repository_interface.dart'; +// Only export PUBLIC API +``` + +**❌ DON'T:** +```dart +// Don't export internal implementation details +export 'src/data/repositories_impl/auth_repository_impl.dart'; +export 'src/presentation/blocs/auth_bloc.dart'; +``` + +## 2. Naming Conventions (Dart Standard) + +| Type | Convention | Example | File Name | +|------|-----------|---------|-----------| +| **Files** | `snake_case` | `user_profile_page.dart` | - | +| **Classes** | `PascalCase` | `UserProfilePage` | - | +| **Variables** | `camelCase` | `userProfile` | - | +| **Interfaces** | End with `Interface` | `AuthRepositoryInterface` | `auth_repository_interface.dart` | +| **Implementations** | End with `Impl` | `AuthRepositoryImpl` | `auth_repository_impl.dart` | +| **BLoCs** | End with `Bloc` or `Cubit` | `AuthBloc`, `ProfileCubit` | `auth_bloc.dart` | +| **Use Cases** | End with `UseCase` | `LoginUseCase` | `login_usecase.dart` | + +## 3. Logic Placement (Zero Tolerance Boundaries) + +### Business Rules → Use Cases ONLY + +**✅ CORRECT:** +```dart +// login_usecase.dart +class LoginUseCase extends UseCase { + @override + Future> call(LoginParams params) async { + // Business logic here: validation, transformation, orchestration + if (params.email.isEmpty) { + return Left(ValidationFailure('Email required')); + } + return await repository.login(params); + } +} +``` + +**❌ FORBIDDEN:** +```dart +// ❌ Business logic in BLoC +class AuthBloc extends Bloc { + on((event, emit) { + if (event.email.isEmpty) { // ← NO! This is business logic + emit(AuthError('Email required')); + } + }); +} + +// ❌ Business logic in Widget +class LoginPage extends StatelessWidget { + void _login() { + if (_emailController.text.isEmpty) { // ← NO! This is business logic + showSnackbar('Email required'); + } + } +} +``` + +### State Logic → BLoCs ONLY + +**✅ CORRECT:** +```dart +// auth_bloc.dart +class AuthBloc extends Bloc { + on((event, emit) async { + emit(AuthLoading()); + final result = await loginUseCase(LoginParams(email: event.email)); + result.fold( + (failure) => emit(AuthError(failure)), + (user) => emit(AuthAuthenticated(user)), + ); + }); +} + +// login_page.dart (StatelessWidget) +class LoginPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is AuthLoading) return LoadingIndicator(); + if (state is AuthError) return ErrorWidget(state.message); + return LoginForm(); + }, + ); + } +} +``` + +**❌ FORBIDDEN:** +```dart +// ❌ setState in Pages for complex state +class LoginPage extends StatefulWidget { + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + bool _isLoading = false; // ← NO! Use BLoC + String? _error; // ← NO! Use BLoC + + void _login() { + setState(() => _isLoading = true); // ← NO! Use BLoC + } +} +``` + +**RECOMMENDATION:** Pages should be `StatelessWidget` with state delegated to BLoCs. + +### Data Transformation → Repositories + +**✅ CORRECT:** +```dart +// profile_repository_impl.dart +class ProfileRepositoryImpl implements ProfileRepositoryInterface { + @override + Future getProfile(String id) async { + final response = await dataConnect.getStaffById(id: id).execute(); + // Data transformation happens here + return Staff( + id: response.data.staff.id, + name: response.data.staff.name, + // Map Data Connect model to Domain entity + ); + } +} +``` + +**❌ FORBIDDEN:** +```dart +// ❌ JSON parsing in UI +class ProfilePage extends StatelessWidget { + @override + Widget build(BuildContext context) { + final json = jsonDecode(response.body); // ← NO! + final name = json['name']; + } +} + +// ❌ JSON parsing in Domain Use Case +class GetProfileUseCase extends UseCase { + @override + Future> call(String id) async { + final response = await http.get('/staff/$id'); + final json = jsonDecode(response.body); // ← NO! + } +} +``` + +### Navigation → Flutter Modular + Safe Extensions + +**✅ CORRECT:** +```dart +// Use Safe Navigation Extensions +import 'package:krow_core/krow_core.dart'; + +// In widget/BLoC: +Modular.to.safePush('/profile'); +Modular.to.safeNavigate('/home'); +Modular.to.popSafe(); + +// Even better: Use Typed Navigators +Modular.to.toStaffHome(); // Defined in StaffNavigator +Modular.to.toShiftDetails(shiftId: '123'); +``` + +**❌ FORBIDDEN:** +```dart +// ❌ Direct Navigator.push +Navigator.push( + context, + MaterialPageRoute(builder: (_) => ProfilePage()), +); + +// ❌ Direct Modular navigation without safety +Modular.to.navigate('/profile'); // ← Can cause blank screens +Modular.to.pop(); // ← Can crash if stack is empty +``` + +**PATTERN:** All navigation MUST have fallback to Home page. Safe extensions automatically handle this. + +### Session Management → DataConnectService + SessionHandlerMixin + +**✅ CORRECT:** +```dart +// In main.dart: +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Initialize session listener (pick allowed roles for app) + DataConnectService.instance.initializeAuthListener( + allowedRoles: ['STAFF', 'BOTH'], // for staff app + ); + + runApp( + SessionListener( // Wraps entire app + child: ModularApp(module: AppModule(), child: AppWidget()), + ), + ); +} + +// In repository: +class ProfileRepositoryImpl implements ProfileRepositoryInterface { + final DataConnectService _service = DataConnectService.instance; + + @override + Future getProfile(String id) async { + // _service.run() handles: + // - Auth validation + // - Token refresh (if <5 min to expiry) + // - Error handling with 3 retries + return await _service.run(() async { + final response = await _service.connector + .getStaffById(id: id) + .execute(); + return _mapToStaff(response.data.staff); + }); + } +} +``` + +**PATTERN:** +- **SessionListener** widget wraps app and shows dialogs for session errors +- **SessionHandlerMixin** in `DataConnectService` provides automatic token refresh +- **3-attempt retry logic** with exponential backoff (1s → 2s → 4s) +- **Role validation** configurable per app + +## 4. Localization Integration (core_localization) + +All user-facing text MUST be localized. + +### String Management + +**✅ CORRECT:** +```dart +// In presentation layer: +import 'package:core_localization/core_localization.dart'; + +class LoginPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Text(context.strings.loginButton); // ← From localization + return ElevatedButton( + onPressed: _login, + child: Text(context.strings.submit), + ); + } +} +``` + +**❌ FORBIDDEN:** +```dart +// ❌ Hardcoded English strings +Text('Login') +Text('Submit') +ElevatedButton(child: Text('Click here')) +``` + +### BLoC Integration + +**✅ CORRECT:** +```dart +// BLoCs emit domain failures (not localized strings) +class AuthBloc extends Bloc { + on((event, emit) async { + final result = await loginUseCase(params); + result.fold( + (failure) => emit(AuthError(failure)), // ← Domain failure + (user) => emit(AuthAuthenticated(user)), + ); + }); +} + +// UI translates failures to user-friendly messages +class LoginPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is AuthError) { + final message = ErrorTranslator.translate( + state.failure, + context.strings, + ); + return ErrorWidget(message); // ← Localized + } + }, + ); + } +} +``` + +### App Setup + +Apps must import `LocalizationModule()`: +```dart +// app_module.dart +class AppModule extends Module { + @override + List get imports => [ + LocalizationModule(), // ← Required + DataConnectModule(), + ]; +} + +// main.dart +runApp( + BlocProvider( // ← Expose locale state + create: (_) => Modular.get(), + child: TranslationProvider( // ← Enable context.strings + child: MaterialApp.router(...), + ), + ), +); +``` + +## 5. Data Connect Integration + +All backend access goes through `DataConnectService`. + +### Repository Pattern + +**Step 1:** Define interface in feature domain: +```dart +// domain/repositories/profile_repository_interface.dart +abstract interface class ProfileRepositoryInterface { + Future getProfile(String id); + Future updateProfile(Staff profile); +} +``` + +**Step 2:** Implement using `DataConnectService.run()`: +```dart +// data/repositories_impl/profile_repository_impl.dart +class ProfileRepositoryImpl implements ProfileRepositoryInterface { + final DataConnectService _service = DataConnectService.instance; + + @override + Future getProfile(String id) async { + return await _service.run(() async { + final response = await _service.connector + .getStaffById(id: id) + .execute(); + return _mapToStaff(response.data.staff); + }); + } +} +``` + +**Benefits of `_service.run()`:** +- ✅ Automatic auth validation +- ✅ Token refresh if needed +- ✅ 3-attempt retry with exponential backoff +- ✅ Consistent error handling + +### Session Store Pattern + +After successful auth, populate session stores: +```dart +// For Staff App: +StaffSessionStore.instance.setSession( + StaffSession( + user: user, + staff: staff, + ownerId: ownerId, + ), +); + +// For Client App: +ClientSessionStore.instance.setSession( + ClientSession( + user: user, + business: business, + ), +); +``` + +**Lazy Loading:** If session is null, fetch via `getStaffById()` or `getBusinessById()` and update store. + +## 6. Prototype Migration Rules + +When migrating from `prototypes/`: + +### ✅ MAY Copy +- Icons, images, assets (but match to design system) +- `build` methods for UI layout structure +- Screen flow and navigation patterns + +### ❌ MUST REJECT & REFACTOR +- `GetX`, `Provider`, or `MVC` patterns +- Any state management not using BLoC +- Direct HTTP calls (must use Data Connect) +- Hardcoded colors/typography (must use design system) +- Global state variables +- Navigation without Modular + +### Colors & Typography Migration +**When matching POC to production:** +1. Find closest color in `UiColors` (don't add new colors without approval) +2. Find closest text style in `UiTypography` +3. Use design system constants, NOT POC hardcoded values + +**DO NOT change the design system itself.** Colors and typography are FINAL. Match your feature to the system, not the other way around. + +## 7. Handling Ambiguity + +If requirements are unclear: + +1. **STOP** - Don't guess domain fields or workflows +2. **ANALYZE** - Refer to: + - Architecture: `apps/mobile/docs/01-architecture-principles.md` + - Design System: `apps/mobile/docs/02-design-system-usage.md` + - Existing features for patterns +3. **DOCUMENT** - Add `// ASSUMPTION: ` if you must proceed +4. **ASK** - Prefer asking user for clarification on business rules + +## 8. Dependencies + +### DO NOT +- Add 3rd party packages without checking `apps/mobile/packages/core` first +- Add `firebase_auth` or `firebase_data_connect` to Feature packages (they belong in `data_connect` only) +- Use `addSingleton` for BLoCs (always use `add` method in Modular) + +### DO +- Use `DataConnectService.instance` for backend operations +- Use Flutter Modular for dependency injection +- Register BLoCs with `i.addSingleton(() => CubitType(...))` +- Register Use Cases as factories or singletons as needed + +## 9. Error Handling Pattern + +### Domain Failures +```dart +// domain/failures/auth_failure.dart +abstract class AuthFailure extends Failure { + const AuthFailure(String message) : super(message); +} + +class InvalidCredentialsFailure extends AuthFailure { + const InvalidCredentialsFailure() : super('Invalid credentials'); +} +``` + +### Repository Error Mapping +```dart +// Map Data Connect exceptions to Domain failures +try { + final response = await dataConnect.query(); + return Right(response); +} on DataConnectException catch (e) { + if (e.message.contains('unauthorized')) { + return Left(InvalidCredentialsFailure()); + } + return Left(ServerFailure(e.message)); +} +``` + +### UI Feedback +```dart +// BLoC emits error state +emit(AuthError(failure)); + +// UI shows user-friendly message +if (state is AuthError) { + final message = ErrorTranslator.translate(state.failure, context.strings); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); +} +``` + +### Session Errors +`SessionListener` automatically shows dialogs for: +- Session expiration +- Token refresh failures +- Network errors during auth + +## 10. Testing Requirements + +### Unit Tests +```dart +// Test use cases with real repository implementations +test('login with valid credentials returns user', () async { + final useCase = LoginUseCase(repository: mockRepository); + final result = await useCase(LoginParams(email: 'test@test.com')); + expect(result.isRight(), true); +}); +``` + +### Widget Tests +```dart +// Test UI widgets and BLoC interactions +testWidgets('shows loading indicator when logging in', (tester) async { + await tester.pumpWidget( + BlocProvider( + create: (_) => authBloc, + child: LoginPage(), + ), + ); + + authBloc.add(LoginRequested(email: 'test@test.com')); + await tester.pump(); + + expect(find.byType(LoadingIndicator), findsOneWidget); +}); +``` + +### Integration Tests +- Test full feature flows end-to-end with Data Connect +- Use dependency injection to swap implementations if needed + +## 11. Clean Code Principles + +### Documentation +- ✅ Add human readable doc comments for `dartdoc` for all classes and methods. +```dart +/// Authenticates user with email and password. +/// +/// Returns [User] on success or [AuthFailure] on failure. +/// Throws [NetworkException] if connection fails. +class LoginUseCase extends UseCase { + // ... +} +``` + +### Single Responsibility +- Keep methods focused on one task +- Extract complex logic to separate methods +- Keep widget build methods concise +- Extract complex widgets to separate files + +### Meaningful Names +```dart +// ✅ GOOD +final isProfileComplete = await checkProfileCompletion(); +final userShifts = await fetchUserShifts(); + +// ❌ BAD +final flag = await check(); +final data = await fetch(); +``` + +## Enforcement Checklist + +Before merging any mobile feature code: + +### Architecture Compliance +- [ ] Feature follows package structure (domain/data/presentation) +- [ ] No business logic in BLoCs or Widgets +- [ ] All state management via BLoCs +- [ ] All backend access via repositories +- [ ] Session accessed via SessionStore, not global state +- [ ] Navigation uses Flutter Modular safe extensions +- [ ] No feature-to-feature imports + +### Code Quality +- [ ] No hardcoded strings (use localization) +- [ ] No hardcoded colors/typography (use design system) +- [ ] All spacing uses UiConstants +- [ ] Doc comments on public APIs +- [ ] Meaningful variable names +- [ ] Zero analyzer warnings + +### Integration +- [ ] Data Connect queries via `_service.run()` +- [ ] Error handling with domain failures +- [ ] Proper dependency injection in modules + +## Summary + +The key principle: **Clean Architecture with zero tolerance for violations.** Business logic in Use Cases, state in BLoCs, data access in Repositories, UI in Widgets. Features are isolated, backend is centralized, localization is mandatory, and design system is immutable. + +When in doubt, refer to existing features following these patterns or ask for clarification. It's better to ask than to introduce architectural debt. diff --git a/.claude/skills/krow-mobile-release/SKILL.md b/.claude/skills/krow-mobile-release/SKILL.md new file mode 100644 index 00000000..78e2b38f --- /dev/null +++ b/.claude/skills/krow-mobile-release/SKILL.md @@ -0,0 +1,778 @@ +--- +name: krow-mobile-release +description: KROW mobile app release process including versioning strategy, CHANGELOG management, GitHub Actions workflows, APK signing, Git tagging, and hotfix procedures. Use this when preparing mobile releases, updating CHANGELOGs, triggering release workflows, creating hotfix branches, troubleshooting release issues, or documenting release features. Covers both staff (worker) and client mobile products across dev/stage/prod environments. +--- + +# KROW Mobile Release Process + +This skill defines the comprehensive release process for KROW mobile applications (staff and client). It covers versioning, changelog management, GitHub Actions automation, and hotfix procedures. + +## When to Use This Skill + +- Preparing for a mobile app release +- Updating CHANGELOG files with new features +- Triggering GitHub Actions release workflows +- Creating hotfix branches for production issues +- Understanding version numbering strategy +- Setting up APK signing secrets +- Troubleshooting release workflow failures +- Documenting release notes +- Managing release cadence (dev → stage → prod) + +## Quick Reference + +### Release Workflows +- **Product Release:** [GitHub Actions - Product Release](https://github.com/Oloodi/krow-workforce/actions/workflows/product-release.yml) +- **Hotfix Creation:** [GitHub Actions - Product Hotfix](https://github.com/Oloodi/krow-workforce/actions/workflows/hotfix-branch-creation.yml) + +### Key Files +- **Staff CHANGELOG:** `apps/mobile/apps/staff/CHANGELOG.md` +- **Client CHANGELOG:** `apps/mobile/apps/client/CHANGELOG.md` +- **Staff Version:** `apps/mobile/apps/staff/pubspec.yaml` +- **Client Version:** `apps/mobile/apps/client/pubspec.yaml` + +### Comprehensive Documentation +For complete details, see: [`docs/RELEASE/mobile-releases.md`](docs/RELEASE/mobile-releases.md) (900+ lines) + +## 1. Versioning Strategy + +### Format + +``` +v{major}.{minor}.{patch}-{milestone} +``` + +**Examples:** +- `v0.0.1-m4` - Milestone 4 release +- `v0.1.0-m5` - Minor version bump for Milestone 5 +- `v1.0.0` - First production release (no milestone suffix) + +### Semantic Versioning Rules + +**Major (X.0.0):** +- Breaking changes +- Complete architecture overhaul +- Incompatible API changes + +**Minor (0.X.0):** +- New features +- Backwards-compatible additions +- Milestone completions + +**Patch (0.0.X):** +- Bug fixes +- Security patches +- Performance improvements + +**Milestone Suffix:** +- `-m1`, `-m2`, `-m3`, `-m4`, etc. +- Indicates pre-production milestone phase +- Removed for production releases + +### Version Location + +Versions are defined in `pubspec.yaml`: + +**Staff App:** +```yaml +# apps/mobile/apps/staff/pubspec.yaml +name: krow_staff_app +version: 0.0.1-m4+1 # version+build_number +``` + +**Client App:** +```yaml +# apps/mobile/apps/client/pubspec.yaml +name: krow_client_app +version: 0.0.1-m4+1 +``` + +**Format:** `version+build` +- `version`: Semantic version with milestone (e.g., `0.0.1-m4`) +- `build`: Build number (increments with each build, e.g., `+1`, `+2`) + +## 2. CHANGELOG Management + +### Format + +Each app maintains a separate CHANGELOG following [Keep a Changelog](https://keepachangelog.com/) format. + +**Structure:** +```markdown +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +### Added +- New feature descriptions + +### Changed +- Modified feature descriptions + +### Fixed +- Bug fix descriptions + +### Removed +- Removed feature descriptions + +## [0.0.1-m4] - Milestone 4 - 2026-03-05 + +### Added +- Profile management with 13 subsections +- Documents & certificates management +- Benefits overview section +- Camera/gallery support for attire verification + +### Changed +- Enhanced session management with auto token refresh + +### Fixed +- Navigation fallback to home on invalid routes +``` + +### Section Guidelines + +**[Unreleased]** +- Work in progress +- Features merged to dev but not released +- Updated continuously during development + +**[Version] - Milestone X - Date** +- Released version +- Format: `[X.Y.Z-mN] - Milestone N - YYYY-MM-DD` +- Organized by change type (Added/Changed/Fixed/Removed) + +### Change Type Definitions + +**Added:** +- New features +- New UI screens +- New API integrations +- New user-facing capabilities + +**Changed:** +- Modifications to existing features +- UI/UX improvements +- Performance enhancements +- Refactored code (if user-facing impact) + +**Fixed:** +- Bug fixes +- Error handling improvements +- Crash fixes +- UI/UX issues resolved + +**Removed:** +- Deprecated features +- Removed screens or capabilities +- Discontinued integrations + +### Writing Guidelines + +**✅ GOOD:** +```markdown +### Added +- Profile management with 13 subsections organized into onboarding, compliance, finances, and support categories +- Documents & certificates management with upload, status tracking, and expiry dates +- Camera and gallery support for attire verification with photo capture +- Benefits overview section displaying perks and company information +``` + +**❌ BAD:** +```markdown +### Added +- New stuff +- Fixed things +- Updated code +``` + +**Key Principles:** +- Be specific and descriptive +- Focus on user-facing changes +- Mention UI screens, features, or capabilities +- Avoid technical jargon users won't understand +- Group related changes together + +### Updating CHANGELOG Workflow + +**Step 1:** During development, add to `[Unreleased]`: +```markdown +## [Unreleased] + +### Added +- New shift calendar view with month/week toggle +- Shift acceptance confirmation dialog + +### Fixed +- Navigation crash when popping empty stack +``` + +**Step 2:** Before release, move to version section: +```markdown +## [0.1.0-m5] - Milestone 5 - 2026-03-15 + +### Added +- New shift calendar view with month/week toggle +- Shift acceptance confirmation dialog + +### Fixed +- Navigation crash when popping empty stack + +## [Unreleased] + +``` + +**Step 3:** Update version in `pubspec.yaml`: +```yaml +version: 0.1.0-m5+1 +``` + +## 3. Git Tagging Strategy + +### Tag Format + +``` +krow-withus--mobile/-vX.Y.Z +``` + +**Components:** +- ``: `worker` (staff) or `client` +- ``: `dev`, `stage`, or `prod` +- `vX.Y.Z`: Semantic version (from pubspec.yaml) + +**Examples:** +``` +krow-withus-worker-mobile/dev-v0.0.1-m4 +krow-withus-worker-mobile/stage-v0.0.1-m4 +krow-withus-worker-mobile/prod-v0.0.1-m4 +krow-withus-client-mobile/dev-v0.0.1-m4 +``` + +### Tag Creation + +Tags are created automatically by GitHub Actions workflows. Manual tagging: + +```bash +# Staff app - dev environment +git tag krow-withus-worker-mobile/dev-v0.0.1-m4 +git push origin krow-withus-worker-mobile/dev-v0.0.1-m4 + +# Client app - prod environment +git tag krow-withus-client-mobile/prod-v1.0.0 +git push origin krow-withus-client-mobile/prod-v1.0.0 +``` + +### Tag Listing + +```bash +# List all mobile tags +git tag -l "krow-withus-*-mobile/*" + +# List staff app tags +git tag -l "krow-withus-worker-mobile/*" + +# List production tags +git tag -l "krow-withus-*-mobile/prod-*" +``` + +## 4. GitHub Actions Workflows + +### 4.1 Product Release Workflow + +**File:** `.github/workflows/product-release.yml` + +**Purpose:** Automated production releases with APK signing + +**Trigger:** Manual dispatch via GitHub UI + +**Inputs:** +- `app`: Select `worker` (staff) or `client` +- `environment`: Select `dev`, `stage`, or `prod` + +**Process:** +1. ✅ Extracts version from `pubspec.yaml` automatically +2. ✅ Builds signed APKs for selected app +3. ✅ Creates GitHub release with CHANGELOG notes +4. ✅ Tags release (e.g., `krow-withus-worker-mobile/dev-v0.0.1-m4`) +5. ✅ Uploads APKs as release assets +6. ✅ Generates step summary with emojis + +**Key Features:** +- **No manual version input** - reads from pubspec.yaml +- **APK signing** - uses GitHub Secrets for keystore +- **CHANGELOG extraction** - pulls release notes automatically +- **Visual feedback** - emojis in all steps + +**Usage:** +``` +1. Go to: GitHub Actions → "📦 Product Release" +2. Click "Run workflow" +3. Select app (worker/client) +4. Select environment (dev/stage/prod) +5. Click "Run workflow" +6. Wait for completion (~5-10 minutes) +``` + +**Release Naming:** +``` +Krow With Us - Worker Product - DEV - v0.0.1-m4 +Krow With Us - Client Product - PROD - v1.0.0 +``` + +### 4.2 Product Hotfix Workflow + +**File:** `.github/workflows/hotfix-branch-creation.yml` + +**Purpose:** Emergency production fix automation + +**Trigger:** Manual dispatch with version input + +**Inputs:** +- `current_version`: Current production version (e.g., `0.0.1-m4`) +- `issue_description`: Brief description of the hotfix + +**Process:** +1. ✅ Creates `hotfix/` branch from latest production tag +2. ✅ Auto-increments PATCH version (e.g., `0.0.1-m4` → `0.0.2-m4`) +3. ✅ Updates `pubspec.yaml` with new version +4. ✅ Updates `CHANGELOG.md` with hotfix section +5. ✅ Creates PR back to main branch +6. ✅ Includes hotfix instructions in PR description + +**Usage:** +``` +1. Go to: GitHub Actions → "🚨 Product Hotfix - Create Branch" +2. Click "Run workflow" +3. Enter current production version (e.g., 0.0.1-m4) +4. Enter issue description (e.g., "critical crash on login") +5. Click "Run workflow" +6. Workflow creates branch and PR +7. Fix bug on hotfix branch +8. Merge PR to main +9. Use Product Release workflow to deploy +``` + +**Hotfix Branch Naming:** +``` +hotfix/0.0.2-m4-critical-crash-on-login +``` + +### 4.3 Helper Scripts + +**Location:** `.github/scripts/` + +**Available Scripts:** +1. **extract-version.sh** - Extract version from pubspec.yaml +2. **generate-tag-name.sh** - Generate standardized tag names +3. **extract-release-notes.sh** - Extract CHANGELOG sections +4. **create-release-summary.sh** - Generate GitHub Step Summary with emojis + +**Script Permissions:** +```bash +chmod +x .github/scripts/*.sh +``` + +**Usage Example:** +```bash +# Extract version from staff app +.github/scripts/extract-version.sh apps/mobile/apps/staff/pubspec.yaml + +# Generate tag name +.github/scripts/generate-tag-name.sh worker dev 0.0.1-m4 + +# Extract release notes for version +.github/scripts/extract-release-notes.sh apps/mobile/apps/staff/CHANGELOG.md 0.0.1-m4 +``` + +## 5. APK Signing Setup + +### Required GitHub Secrets (24 Total) + +**Per App (12 secrets each):** + +**Staff (Worker) App:** +``` +STAFF_UPLOAD_KEYSTORE_BASE64 # Base64-encoded keystore file +STAFF_UPLOAD_STORE_PASSWORD # Keystore password +STAFF_UPLOAD_KEY_ALIAS # Key alias +STAFF_UPLOAD_KEY_PASSWORD # Key password +STAFF_KEYSTORE_PROPERTIES_BASE64 # Base64-encoded key.properties file +``` + +**Client App:** +``` +CLIENT_UPLOAD_KEYSTORE_BASE64 +CLIENT_UPLOAD_STORE_PASSWORD +CLIENT_UPLOAD_KEY_ALIAS +CLIENT_UPLOAD_KEY_PASSWORD +CLIENT_KEYSTORE_PROPERTIES_BASE64 +``` + +### Generating Secrets + +**Step 1: Create Keystore** + +```bash +# For staff app +keytool -genkey -v \ + -keystore staff-upload-keystore.jks \ + -keyalg RSA \ + -keysize 2048 \ + -validity 10000 \ + -alias staff-upload + +# For client app +keytool -genkey -v \ + -keystore client-upload-keystore.jks \ + -keyalg RSA \ + -keysize 2048 \ + -validity 10000 \ + -alias client-upload +``` + +**Step 2: Base64 Encode** + +```bash +# Encode keystore +base64 -i staff-upload-keystore.jks | tr -d '\n' > staff-keystore.txt + +# Encode key.properties +base64 -i key.properties | tr -d '\n' > key-props.txt +``` + +**Step 3: Add to GitHub Secrets** + +``` +Repository → Settings → Secrets and variables → Actions → New repository secret +``` + +Add each secret: +- Name: `STAFF_UPLOAD_KEYSTORE_BASE64` +- Value: Contents of `staff-keystore.txt` + +Repeat for all 24 secrets. + +### key.properties Format + +```properties +storePassword=your_store_password +keyPassword=your_key_password +keyAlias=staff-upload +storeFile=../staff-upload-keystore.jks +``` + +## 6. Release Process (Step-by-Step) + +### Standard Release (Dev/Stage/Prod) + +**Step 1: Prepare CHANGELOG** + +Update `CHANGELOG.md` with all changes since last release: +```markdown +## [0.1.0-m5] - Milestone 5 - 2026-03-15 + +### Added +- Shift calendar with month/week views +- Enhanced navigation with typed routes +- Profile completion wizard + +### Fixed +- Session token refresh timing +- Navigation fallback logic +``` + +**Step 2: Update Version** + +Edit `pubspec.yaml`: +```yaml +version: 0.1.0-m5+1 # Changed from 0.0.1-m4+1 +``` + +**Step 3: Commit and Push** + +```bash +git add apps/mobile/apps/staff/CHANGELOG.md +git add apps/mobile/apps/staff/pubspec.yaml +git commit -m "chore(staff): prepare v0.1.0-m5 release" +git push origin dev +``` + +**Step 4: Trigger Workflow** + +1. Go to GitHub Actions → "📦 Product Release" +2. Click "Run workflow" +3. Select branch: `dev` +4. Select app: `worker` (or `client`) +5. Select environment: `dev` (or `stage`, `prod`) +6. Click "Run workflow" + +**Step 5: Monitor Progress** + +Watch workflow execution: +- ⏳ Version extraction +- ⏳ APK building +- ⏳ APK signing +- ⏳ GitHub Release creation +- ⏳ Tag creation +- ⏳ Asset upload + +**Step 6: Verify Release** + +1. Check GitHub Releases page +2. Download APK to verify +3. Install on test device +4. Verify version in app + +### Hotfix Release + +**Step 1: Identify Production Issue** + +- Critical bug in production +- User-reported crash +- Security vulnerability + +**Step 2: Trigger Hotfix Workflow** + +1. Go to GitHub Actions → "🚨 Product Hotfix - Create Branch" +2. Click "Run workflow" +3. Enter current version: `0.0.1-m4` +4. Enter description: `Critical crash on login screen` +5. Click "Run workflow" + +**Step 3: Review Created Branch** + +Workflow creates: +- Branch: `hotfix/0.0.2-m4-critical-crash-on-login` +- PR to `main` branch +- Updated `pubspec.yaml`: `0.0.2-m4+1` +- Updated `CHANGELOG.md` with hotfix section + +**Step 4: Fix Bug** + +```bash +git checkout hotfix/0.0.2-m4-critical-crash-on-login + +# Make fixes +# ... code changes ... + +git add . +git commit -m "fix(auth): resolve crash on login screen" +git push origin hotfix/0.0.2-m4-critical-crash-on-login +``` + +**Step 5: Merge PR** + +1. Review PR on GitHub +2. Approve and merge to `main` +3. Delete hotfix branch + +**Step 6: Release to Production** + +1. Use Product Release workflow +2. Select `main` branch +3. Select `prod` environment +4. Deploy hotfix + +## 7. Release Cadence + +### Development (dev) + +- **Frequency:** Multiple times per day +- **Purpose:** Testing features in dev environment +- **Branch:** `dev` +- **Audience:** Internal development team +- **Approval:** Not required + +### Staging (stage) + +- **Frequency:** 1-2 times per week +- **Purpose:** QA testing, stakeholder demos +- **Branch:** `main` +- **Audience:** QA team, stakeholders +- **Approval:** Tech lead approval + +### Production (prod) + +- **Frequency:** Every 2-3 weeks (milestone completion) +- **Purpose:** End-user releases +- **Branch:** `main` +- **Audience:** All users +- **Approval:** Product owner + tech lead approval + +### Milestone Releases + +- **Frequency:** Every 2-4 weeks +- **Version Bump:** Minor version (e.g., `0.1.0-m5` → `0.2.0-m6`) +- **Process:** + 1. Complete all milestone features + 2. Update CHANGELOG with comprehensive release notes + 3. Deploy to stage for final QA + 4. After approval, deploy to prod + 5. Create GitHub release with milestone summary + +## 8. Troubleshooting + +### Workflow Fails: Version Extraction + +**Error:** "Could not extract version from pubspec.yaml" + +**Solutions:** +1. Verify `pubspec.yaml` exists at expected path +2. Check version format: `version: X.Y.Z-mN+B` +3. Ensure no extra spaces or tabs +4. Verify file is committed and pushed + +### Workflow Fails: APK Signing + +**Error:** "Keystore password incorrect" + +**Solutions:** +1. Verify GitHub Secrets are set correctly +2. Re-generate and re-encode keystore +3. Check key.properties format +4. Ensure passwords don't contain special characters that need escaping + +### Workflow Fails: CHANGELOG Extraction + +**Error:** "Could not find version in CHANGELOG" + +**Solutions:** +1. Verify CHANGELOG format matches: `## [X.Y.Z-mN] - Milestone N - YYYY-MM-DD` +2. Check square brackets are present +3. Ensure version matches pubspec.yaml +4. Add version section if missing + +### Tag Already Exists + +**Error:** "tag already exists" + +**Solutions:** +1. Delete existing tag locally and remotely: +```bash +git tag -d krow-withus-worker-mobile/dev-v0.0.1-m4 +git push origin :refs/tags/krow-withus-worker-mobile/dev-v0.0.1-m4 +``` +2. Re-run workflow + +### Build Fails: Flutter Errors + +**Error:** "flutter build failed" + +**Solutions:** +1. Test build locally first: +```bash +cd apps/mobile/apps/staff +flutter build apk --release +``` +2. Fix any analyzer errors +3. Ensure all dependencies are compatible +4. Clear build cache: +```bash +flutter clean +flutter pub get +``` + +## 9. Local Testing + +Before triggering workflows, test builds locally: + +### Building APKs Locally + +**Staff App:** +```bash +cd apps/mobile/apps/staff +flutter clean +flutter pub get +flutter build apk --release +``` + +**Client App:** +```bash +cd apps/mobile/apps/client +flutter clean +flutter pub get +flutter build apk --release +``` + +### Testing Release Notes + +Extract CHANGELOG section: +```bash +.github/scripts/extract-release-notes.sh \ + apps/mobile/apps/staff/CHANGELOG.md \ + 0.0.1-m4 +``` + +### Verifying Version + +Extract version from pubspec: +```bash +.github/scripts/extract-version.sh \ + apps/mobile/apps/staff/pubspec.yaml +``` + +## 10. Best Practices + +### CHANGELOG +- ✅ Update continuously during development +- ✅ Be specific and user-focused +- ✅ Group related changes +- ✅ Include UI/UX changes +- ❌ Don't include technical debt or refactoring (unless user-facing) +- ❌ Don't use vague descriptions + +### Versioning +- ✅ Use semantic versioning strictly +- ✅ Increment patch for bug fixes +- ✅ Increment minor for new features +- ✅ Keep milestone suffix until production +- ❌ Don't skip versions +- ❌ Don't use arbitrary version numbers + +### Git Tags +- ✅ Follow standard format +- ✅ Let workflow create tags automatically +- ✅ Keep tags synced with releases +- ❌ Don't create tags manually unless necessary +- ❌ Don't reuse deleted tags + +### Workflows +- ✅ Test builds locally first +- ✅ Monitor workflow execution +- ✅ Verify release assets +- ✅ Test APK on device before announcing +- ❌ Don't trigger multiple workflows simultaneously +- ❌ Don't bypass approval process + +## Summary + +**Release Process Overview:** +1. Update CHANGELOG with changes +2. Update version in pubspec.yaml +3. Commit and push to appropriate branch +4. Trigger Product Release workflow +5. Monitor execution and verify release +6. Test APK on device +7. Announce to team/users + +**Key Files:** +- `apps/mobile/apps/staff/CHANGELOG.md` +- `apps/mobile/apps/client/CHANGELOG.md` +- `apps/mobile/apps/staff/pubspec.yaml` +- `apps/mobile/apps/client/pubspec.yaml` + +**Key Workflows:** +- Product Release (standard releases) +- Product Hotfix (emergency fixes) + +**For Complete Details:** +See [`docs/RELEASE/mobile-releases.md`](docs/RELEASE/mobile-releases.md) - 900+ line comprehensive guide with: +- Detailed APK signing setup +- Complete troubleshooting guide +- All helper scripts documentation +- Release checklist +- Security best practices + +When in doubt, refer to the comprehensive documentation or ask for clarification before releasing to production. diff --git a/.claude/skills/krow-paper-design/SKILL.md b/.claude/skills/krow-paper-design/SKILL.md new file mode 100644 index 00000000..df9b2994 --- /dev/null +++ b/.claude/skills/krow-paper-design/SKILL.md @@ -0,0 +1,413 @@ +--- +name: krow-paper-design +description: KROW Paper design file conventions covering design tokens, component patterns, screen structure, and naming rules. Use this when creating or updating screens in the Paper design tool, auditing designs for token compliance, building new flows, or restructuring existing frames. Ensures visual consistency across all Paper design files for the KROW staff and client apps. +--- + +# KROW Paper Design Conventions + +This skill defines the design token system, component patterns, screen structure conventions, and workflow rules established for the KROW Design Revamp Paper file. All design work in Paper must follow these conventions. + +## When to Use This Skill + +- Creating new screens or flows in Paper +- Updating existing frames to match the design system +- Auditing designs for token compliance +- Adding components (buttons, chips, inputs, badges, cards) +- Structuring shift detail pages, onboarding flows, or list screens +- Setting up navigation patterns (back buttons, bottom nav, CTAs) +- Reviewing Paper designs before handoff to development + +## 1. Design Tokens + +### Color Palette + +| Token | Hex | Usage | +|-------|-----|-------| +| Primary | `#0A39DF` | CTAs, active states, links, selected chips, nav active icons, pay rates | +| Foreground | `#121826` | Headings, primary text, dark UI elements | +| Text Secondary | `#6A7382` | Labels, captions, inactive nav, section headers, placeholder text, back chevrons | +| Secondary BG | `#F1F3F5` | Subtle backgrounds, dividers, map placeholders | +| Border | `#D1D5DB` | Card borders, unselected chip borders, outline button borders | +| Input Border | `#E2E8F0` | Text input borders (lighter than general border) | +| Destructive | `#F04444` | Error states, destructive actions (e.g., Request Swap) | +| Background | `#FAFBFC` | Page/artboard background | +| Card BG | `#FFFFFF` | Card surfaces, input backgrounds | +| Success | `#059669` | Active status dot, checkmark icons, requirement met | +| Warning Amber | `#D97706` | Urgent/Pending badge text | + +### Semantic Badge Colors + +| Badge | Background | Text Color | +|-------|-----------|------------| +| Active | `#ECFDF5` | `#059669` | +| Confirmed | `#EBF0FF` | `#0A39DF` | +| Pending | `#FEF9EE` | `#D97706` | +| Urgent | `#FEF9EE` | `#D97706` | +| One-Time | `#ECFDF5` | `#059669` | +| Recurring | `#EBF0FF` | `#0A39DF` (use `#EFF6FF` bg on detail pages) | + +### Typography + +| Style | Font | Size | Weight | Line Height | Usage | +|-------|------|------|--------|-------------|-------| +| Display | Inter Tight | 28px | 700 | 34px | Page titles (Find Shifts, My Shifts) | +| H1 | Inter Tight | 24px | 700 | 30px | Detail page titles (venue names) | +| H2 | Inter Tight | 20px | 700 | 26px | Section headings | +| H3 | Inter Tight | 18px | 700 | 22px | Card titles, schedule values | +| Body Large | Manrope | 16px | 600 | 20px | Button text, CTA labels | +| Body Default | Manrope | 14px | 400-500 | 18px | Body text, descriptions | +| Body Small | Manrope | 13px | 400-500 | 16px | Card metadata, time/pay info | +| Caption | Manrope | 12px | 500-600 | 16px | Small chip text, tab labels | +| Section Label | Manrope | 11px | 700 | 14px | Uppercase section headers (letter-spacing: 0.06em) | +| Badge Text | Manrope | 11px | 600-700 | 14px | Status badge labels (letter-spacing: 0.04em) | +| Nav Label | Manrope | 10px | 600 | 12px | Bottom nav labels | + +### Spacing + +| Token | Value | Usage | +|-------|-------|-------| +| Page padding | 24px | Horizontal padding from screen edge | +| Section gap | 16-24px | Between major content sections | +| Group gap | 8-12px | Within a section (e.g., label to input) | +| Element gap | 4px | Tight spacing (e.g., subtitle under title) | +| Bottom safe area | 40px | Padding below last element / CTA | + +### Border Radii + +| Token | Value | Usage | +|-------|-------|-------| +| sm | 8px | Small chips, badges, status pills, map placeholder | +| md | 12px | Cards, inputs, location cards, contact cards, search fields | +| lg | 14px | Buttons, CTA containers, shift cards (Find Shifts) | +| xl | 24px | Not commonly used | +| pill | 999px | Progress bar segments only | + +## 2. Component Patterns + +### Buttons + +**Primary CTA:** +- Background: `#0A39DF`, radius: 14px, height: 52px +- Text: Manrope 16px/600, color: `#FFFFFF` +- Padding: 16px vertical, 16px horizontal + +**Secondary/Outline Button:** +- Background: `#FFFFFF`, border: 1.5px `#D1D5DB`, radius: 14px, height: 52px +- Text: Manrope 16px/600, color: `#121826` + +**Destructive Outline Button:** +- Background: `#FFFFFF`, border: 1.5px `#F04444`, radius: 14px +- Text: Manrope 14px/600, color: `#F04444` + +**Back Icon Button (Bottom CTA):** +- 52x52px square, border: 1.5px `#D1D5DB`, radius: 14px, background: `#FFFFFF` +- Contains chevron-left SVG (20x20, viewBox 0 0 24 24, stroke `#121826`, strokeWidth 2) +- Path: `M15 18L9 12L15 6` + +### Chips + +**Default (Large) - for role/skill selection:** +- Selected: bg `#EFF6FF`, border 1.5px `#0A39DF`, radius 10px, padding 12px/16px + - Checkmark icon (14x14, stroke `#0A39DF`), text Manrope 14px/600 `#0A39DF` +- Unselected: bg `#FFFFFF`, border 1.5px `#6A7382`, radius 10px, padding 12px/16px + - Text Manrope 14px/500 `#6A7382` + +**Small - for tabs, filters:** +- Selected: bg `#EFF6FF`, border 1.5px `#0A39DF`, radius 8px, padding 6px/12px + - Checkmark icon (12x12), text Manrope 12px/600 `#0A39DF` +- Unselected: bg `#FFFFFF`, border 1.5px `#D1D5DB`, radius 8px, padding 6px/12px + - Text Manrope 12px/500 `#6A7382` +- Active (filled): bg `#0A39DF`, radius 8px, padding 6px/12px + - Text Manrope 12px/600 `#FFFFFF` +- Dark (filters button): bg `#121826`, radius 8px, padding 6px/12px + - Text Manrope 12px/600 `#FFFFFF`, with leading icon + +**Status Badges:** +- Radius: 8px, padding: 4px/8px +- Text: Manrope 11px/600-700, uppercase, letter-spacing 0.04em +- Colors follow semantic badge table above + +### Text Inputs + +- Border: 1.5px `#E2E8F0`, radius: 12px, padding: 12px/14px +- Background: `#FFFFFF` +- Placeholder: Manrope 14px/400, color `#6A7382` +- Filled: Manrope 14px/500, color `#121826` +- Label above: Manrope 14px/500, color `#121826` +- Focused: border color `#0A39DF`, border-width 2px +- Error: border color `#F04444`, helper text `#F04444` + +### Cards (Shift List Items) + +- Background: `#FFFFFF`, border: 1px `#D1D5DB`, radius: 12-14px +- Padding: 16px +- Content: venue name (Manrope 15px/600 `#121826`), subtitle (Manrope 13px/400 `#6A7382`) +- Metadata row: icon (14px, `#6A7382`) + text (Manrope 13px/500 `#6A7382`) +- Pay rate: Inter Tight 18px/700 `#0A39DF` + +### Schedule/Pay Info Cards + +- Two-column layout with 12px gap +- Background: `#FFFFFF`, border: 1px `#D1D5DB`, radius: 12px, padding: 16px +- Label: Manrope 11px/500-700 uppercase `#6A7382` (letter-spacing 0.05em) +- Value: Inter Tight 18px/700 `#121826` (schedule) or `#121826` (pay) +- Sub-text: Manrope 13px/400 `#6A7382` + +### Contact/Info Rows + +- Container: radius 12px, border 1px `#D1D5DB`, background `#FFFFFF`, overflow clip +- Row: padding 13px/16px, gap 10px, border-bottom 1px `#F1F3F5` (except last) +- Icon: 16px, stroke `#6A7382` +- Label: Manrope 13px/500 `#6A7382`, width 72px fixed +- Value: Manrope 13px/500 `#121826` (or `#0A39DF` for phone/links) + +### Section Headers + +- Text: Manrope 11px/700, uppercase, letter-spacing 0.06em, color `#6A7382` +- Gap to content below: 10px + +## 3. Screen Structure + +### Artboard Setup + +- Width: 390px (iPhone standard) +- Height: 844px (default), or `fit-content` for scrollable detail pages +- Background: `#FAFBFC` +- Flex column layout, overflow: clip + +### Frame Naming Convention + +``` +-
-- +``` + +Examples: +- `staff-1-1-splash` +- `staff-2-3-personal-information` +- `staff-4-1-my-shifts` +- `staff-5-2-shift-details` +- `shift-5-3-confirmation` + +Section headers use: ` -
` (e.g., `4 - My Shifts`) + +### Status Bar + +- Height: 44px, full width (390px) +- Left: "9:41" text (system font) +- Right: Signal, WiFi, Battery SVG icons (68px wide) + +### Header Back Button + +- Placed below status bar in a combined "Status Bar + Back" frame (390x72px) +- Chevron SVG: 20x20, viewBox 0 0 24 24, stroke `#6A7382`, strokeWidth 2 +- Path: `M15 18L9 12L15 6` +- Back button frame: 390x28px, padding-left: 24px + +### Progress Bar (Onboarding) + +- Container: 342px wide (24px margins), 3px height segments +- Segments: pill radius (999px), gap between +- Filled: `#0A39DF`, Unfilled: `#F1F3F5` + +### Bottom CTA Convention + +- Pinned to bottom using `marginTop: auto` on the CTA container +- Layout: flex row, gap 12px, padding 0 24px +- Back button: 52x52px icon-only button with chevron-left (stroke `#121826`) +- Primary CTA: flex 1, height 52px, radius 14px, bg `#0A39DF` +- Bottom safe padding: 40px (on artboard paddingBottom) + +### Bottom Navigation Bar + +- Full width, padding: 10px top, 28px bottom +- Border-top: 1px `#F1F3F5`, background: `#FFFFFF` +- 5 items: Home, Shifts, Find, Payments, Profile +- Active: icon stroke `#0A39DF`, label Manrope 10px/600 `#0A39DF` +- Inactive: icon stroke `#6A7382`, label Manrope 10px/600 `#6A7382` +- Active icon may have light fill (e.g., `#EBF0FF` on calendar/search) + +## 4. Screen Templates + +### List Screen (My Shifts, Find Shifts) + +``` +Artboard (390x844, bg #FAFBFC) + Status Bar (390x44) + Header Section + Page Title (Display: Inter Tight 28px/700) + Tab/Filter Chips (Small chip variant) + Content + Date Header (Section label style, uppercase) + Shift Cards (12px radius, 1px border #D1D5DB) + Bottom Nav Bar +``` + +### Detail Screen (Shift Details) + +``` +Artboard (390x fit-content, bg #FAFBFC) + Status Bar (390x44) + Header Bar (Back chevron + "Shift Details" title + share icon) + Badges Row (status chips) + Role Title (H1) + Venue (with avatar) + Schedule/Pay Cards (two-column) + Job Description (section label + body text) + Location (card with map + address) + Requirements (section label + checkmark list) + Shift Contact (section label + contact card with rows) + [Optional] Note from Manager (warm bg card) + Bottom CTA (pinned) +``` + +### Onboarding Screen + +``` +Artboard (390x844, bg #FAFBFC, justify: flex-start, paddingBottom: 40px) + Status Bar + Back (390x72) + Progress Bar (342px, 3px segments) + Step Counter ("Step X of Y" - Body Small) + Page Title (H1: Inter Tight 24px/700) + [Optional] Subtitle (Body Default) + Form Content (inputs, chips, sliders) + Bottom CTA (marginTop: auto - back icon + Continue) +``` + +### Confirmation Screen + +``` +Artboard (390x844, bg #FAFBFC) + Status Bar + Centered Content + Success Icon (green circle + checkmark) + Title (Display: Inter Tight 26px/700, centered) + Subtitle (Body Default, centered, #6A7382) + Details Card (border #D1D5DB, rows with label/value pairs) + Bottom CTAs (primary + outline) +``` + +## 5. Workflow Rules + +### Write Incrementally + +Each `write_html` call should produce ONE visual group: +- A header, a card, a single list row, a button bar, a section +- Never batch an entire screen in one call + +### Review Checkpoints + +After every 2-3 modifications, take a screenshot and evaluate: +- **Spacing**: Uneven gaps, cramped groups +- **Typography**: Hierarchy, readability, correct font/weight +- **Contrast**: Text legibility, element distinction +- **Alignment**: Vertical lanes, horizontal alignment +- **Clipping**: Content cut off at edges +- **Token compliance**: All values match design system tokens + +### Color Audit Process + +When updating frames to match the design system: +1. Get computed styles for all text, background, border elements +2. Map old colors to design system tokens: + - Dark navy (`#0F4C81`, `#1A3A5C`) -> Primary `#0A39DF` + - Near-black (`#111827`, `#0F172A`) -> Foreground `#121826` + - Gray variants (`#94A3B8`, `#64748B`, `#475569`) -> Text Secondary `#6A7382` + - Green accents (`#20B486`) -> Primary `#0A39DF` (for pay) or `#059669` (for status) +3. Batch update using `update_styles` with multiple nodeIds per style change +4. Verify with screenshots + +### Structural Consistency + +When creating matching screens (e.g., two shift detail views): +- Use identical section ordering +- Match section header styles (11px/700 uppercase `#6A7382`) +- Use same card/row component patterns +- Maintain consistent padding and gap values + +## 6. SVG Icon Patterns + +### Chevron Left (Back) +```html + + + +``` + +### Map Pin +```html + + + + +``` + +### User (Supervisor) +```html + + + + +``` + +### Phone +```html + + + +``` + +### Checkmark (Requirement Met) +```html + + + + +``` + +### Chip Checkmark +```html + + + + + + + + + +``` + +## 7. Anti-Patterns + +### Colors +- Never use `#0F4C81`, `#1A3A5C` (old navy) - use `#0A39DF` (Primary) +- Never use `#111827`, `#0F172A` - use `#121826` (Foreground) +- Never use `#94A3B8`, `#64748B`, `#475569` - use `#6A7382` (Text Secondary) +- Never use `#20B486` for pay rates - use `#0A39DF` (Primary) +- Never use `#E2E8F0` for card borders - use `#D1D5DB` (Border) + +### Components +- Never use pill radius (999px) for chips or badges - use 8px or 10px +- Never use gradient backgrounds on buttons +- Never mix font families within a role (headings = Inter Tight, body = Manrope) +- Never place back buttons at the bottom of frames - always after status bar +- Never hardcode CTA position - use `marginTop: auto` for bottom pinning + +### Structure +- Never batch an entire screen in one `write_html` call +- Never skip review checkpoints after 2-3 modifications +- Never create frames without following the naming convention +- Never use `justifyContent: space-between` on artboards with many direct children - use `marginTop: auto` on the CTA instead + +## Summary + +**The design file is the source of truth for visual direction.** Every element must use the established tokens: + +1. **Colors**: 7 core tokens + semantic badge colors +2. **Typography**: Inter Tight (headings) + Manrope (body), defined scale +3. **Spacing**: 24px page padding, 16-24px section gaps, 40px bottom safe area +4. **Radii**: 8px (chips/badges), 12px (cards/inputs), 14px (buttons/CTAs) +5. **Components**: Buttons, chips (large/small), inputs, cards, badges, nav bars +6. **Structure**: Status bar > Back > Content > Bottom CTA (pinned) +7. **Naming**: `-
--` + +When in doubt, screenshot an existing screen and match its patterns exactly. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..23463707 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,99 @@ +## 📋 Description + + + +--- + +## 🔗 Related Issues + + + +Closes # +Related to # + +--- + +## 🎯 Type of Change + + + +- [ ] 🐛 **Bug fix** (non-breaking change that fixes an issue) +- [ ] ✨ **Feature** (non-breaking change that adds functionality) +- [ ] 📝 **Documentation** (changes to docs, comments, or README) +- [ ] 🔧 **Refactor** (code change that doesn't affect functionality) +- [ ] ⚡ **Performance** (improvement in performance or optimization) +- [ ] 🔐 **Security** (security fix or improvement) +- [ ] 🎨 **Style** (formatting, linting, or minor code style changes) +- [ ] 🏗️ **Architecture** (significant structural changes) + +--- + +## 📦 Affected Areas + + + +- [ ] 📱 **Mobile** (Flutter - Client/Worker app) +- [ ] 🌐 **Web** (React Dashboard) +- [ ] 🔌 **Backend** (APIs, Data Connect, Cloud Functions) +- [ ] 🗄️ **Database** (Schema changes, migrations) +- [ ] 🚀 **CI/CD** (GitHub Actions, deployment configs) +- [ ] 📚 **Documentation** (Docs, onboarding guides) + +--- + +## ✅ Testing + + + +**Test Details:** + + + +--- + +## 🔄 Breaking Changes + + + +- [ ] No breaking changes +- [ ] Yes, breaking changes: + +**Details:** + + + +--- + +## 🎯 Checklist + + + +- [ ] Code follows project style guidelines +- [ ] Self-review completed +- [ ] Comments added for complex logic +- [ ] Documentation updated (if applicable) +- [ ] No new console warnings/errors +- [ ] Tests pass locally +- [ ] Branch is up-to-date with `dev` +- [ ] Commit messages are clear and descriptive +- [ ] Sensitive data is not committed +- [ ] Environment variables documented (if added) + +--- + +## 📝 Additional Notes + + + + +--- + +## 🔍 Review Checklist for Maintainers + +- [ ] Code quality and readability +- [ ] Design patterns follow project conventions +- [ ] Performance implications reviewed +- [ ] Security concerns addressed +- [ ] Documentation is complete +- [ ] Breaking changes properly communicated +- [ ] Cross-platform compatibility (if applicable) diff --git a/.github/scripts/attach-apk-to-release.sh b/.github/scripts/attach-apk-to-release.sh new file mode 100755 index 00000000..4491178f --- /dev/null +++ b/.github/scripts/attach-apk-to-release.sh @@ -0,0 +1,60 @@ +#!/bin/bash + +# ============================================================================= +# Attach APK to GitHub Release +# ============================================================================= +# This script attaches a built APK to a GitHub Release with proper naming +# +# Usage: +# ./attach-apk-to-release.sh +# +# Arguments: +# tag_name - Git tag name (e.g., krow-withus-worker-mobile/dev-v0.1.0) +# app - worker-mobile-app or client-mobile-app +# app_name - staff or client (internal build folder name) +# version - Version number (e.g., 0.1.0) +# environment - dev, stage, or prod +# +# Environment Variables: +# GH_TOKEN - GitHub token for gh CLI authentication +# ============================================================================= + +set -e + +TAG_NAME="$1" +APP="$2" +APP_NAME="$3" +VERSION="$4" +ENV="$5" + +if [ -z "$TAG_NAME" ] || [ -z "$APP" ] || [ -z "$APP_NAME" ] || [ -z "$VERSION" ] || [ -z "$ENV" ]; then + echo "❌ Error: Missing required arguments" >&2 + echo "Usage: $0 " >&2 + exit 1 +fi + +# Find APK in build output +APK_PATH="apps/mobile/apps/${APP_NAME}/build/app/outputs/flutter-apk/app-release.apk" + +if [ ! -f "$APK_PATH" ]; then + echo "❌ Error: APK not found at $APK_PATH" >&2 + echo "Searching for APK files..." >&2 + find apps/mobile/apps/${APP_NAME} -name "*.apk" + exit 1 +fi + +# Create proper APK name based on app type +if [ "$APP" = "worker-mobile-app" ]; then + APK_NAME="krow-withus-worker-mobile-${ENV}-v${VERSION}.apk" +else + APK_NAME="krow-withus-client-mobile-${ENV}-v${VERSION}.apk" +fi + +# Copy APK with proper name +cp "$APK_PATH" "/tmp/$APK_NAME" + +# Upload to GitHub Release +echo "📤 Uploading $APK_NAME to release $TAG_NAME..." >&2 +gh release upload "$TAG_NAME" "/tmp/$APK_NAME" --clobber + +echo "✅ APK attached to release: $APK_NAME" >&2 diff --git a/.github/scripts/create-release-summary.sh b/.github/scripts/create-release-summary.sh new file mode 100755 index 00000000..ddefb1d9 --- /dev/null +++ b/.github/scripts/create-release-summary.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# Generate release summary for GitHub Actions +# Usage: ./create-release-summary.sh + +set -e + +APP=$1 +ENV=$2 +VERSION=$3 +TAG_NAME=$4 + +if [ -z "$APP" ] || [ -z "$ENV" ] || [ -z "$VERSION" ] || [ -z "$TAG_NAME" ]; then + echo "❌ Error: Missing required parameters" + echo "Usage: ./create-release-summary.sh " + exit 1 +fi + +# Determine display names +if [ "$APP" = "worker-mobile-app" ]; then + APP_DISPLAY="Worker Product" + APP_EMOJI="👷" +else + APP_DISPLAY="Client Product" + APP_EMOJI="💼" +fi + +ENV_UPPER=$(echo "$ENV" | tr '[:lower:]' '[:upper:]') +RELEASE_NAME="Krow With Us - ${APP_DISPLAY} - ${ENV_UPPER} - v${VERSION}" + +# Environment emoji +case "$ENV" in + dev) + ENV_EMOJI="🔧" + ;; + stage) + ENV_EMOJI="🎭" + ;; + prod) + ENV_EMOJI="🚀" + ;; + *) + ENV_EMOJI="📦" + ;; +esac + +# Generate summary +cat << EOF >> $GITHUB_STEP_SUMMARY +## 🎉 Release Created Successfully + +### ${APP_EMOJI} Application Details +- **App:** ${APP_DISPLAY} +- **Environment:** ${ENV_EMOJI} ${ENV_UPPER} +- **Version:** \`${VERSION}\` +- **Tag:** \`${TAG_NAME}\` + +### 📦 Release Information +**Release Name:** ${RELEASE_NAME} + +### ✅ Next Steps + +1. 🔍 **Verify** the tag and release on GitHub +2. 🏗️ **Trigger** CodeMagic build (if configured) +3. 📱 **Monitor** app store deployment +4. 📚 **Update** project documentation if needed +5. 🎯 **Communicate** release to stakeholders + +### 🔗 Quick Links +- [View Tag](../../releases/tag/${TAG_NAME}) +- [Release Documentation](../../docs/release/MOBILE_RELEASE_PLAN.md) +- [CHANGELOG](../../apps/mobile/apps/${APP}/CHANGELOG.md) +EOF + +echo "✅ Summary generated successfully" diff --git a/.github/scripts/extract-release-notes.sh b/.github/scripts/extract-release-notes.sh new file mode 100755 index 00000000..408d969f --- /dev/null +++ b/.github/scripts/extract-release-notes.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# Extract release notes from CHANGELOG for a specific version +# Usage: ./extract-release-notes.sh + +set -e + +APP=$1 +VERSION=$2 +ENV=$3 +TAG_NAME=$4 +OUTPUT_FILE=$5 + +if [ -z "$APP" ] || [ -z "$VERSION" ] || [ -z "$ENV" ] || [ -z "$TAG_NAME" ] || [ -z "$OUTPUT_FILE" ]; then + echo "❌ Error: Missing required parameters" >&2 + echo "Usage: ./extract-release-notes.sh " >&2 + exit 1 +fi + +# Determine CHANGELOG path and app name +if [ "$APP" = "worker-mobile-app" ]; then + CHANGELOG_PATH="apps/mobile/apps/staff/CHANGELOG.md" + APP_NAME="Staff Product (Worker)" +else + CHANGELOG_PATH="apps/mobile/apps/client/CHANGELOG.md" + APP_NAME="Client Product" +fi + +# Try to extract release notes for this version +if [ -f "$CHANGELOG_PATH" ]; then + echo "📝 Found CHANGELOG at $CHANGELOG_PATH" >&2 + + # Extract section for this version + # Look for ## [vVERSION] or ## [VERSION] and collect content until next ## [ header + # Try with 'v' prefix first (common format), then without + CHANGELOG_CONTENT=$(awk "/^## \[v${VERSION}\]/{flag=1; next} /^## \[/{flag=0} flag" "$CHANGELOG_PATH") + + # If still empty, try without 'v' prefix + if [ -z "$CHANGELOG_CONTENT" ]; then + CHANGELOG_CONTENT=$(awk "/^## \[${VERSION}\]/{flag=1; next} /^## \[/{flag=0} flag" "$CHANGELOG_PATH") + fi + + if [ -z "$CHANGELOG_CONTENT" ]; then + echo "⚠️ Warning: No CHANGELOG entry found for version $VERSION" >&2 + NOTES="**Environment:** $ENV +**Tag:** $TAG_NAME + +## What is new in this release + +⚠️ No CHANGELOG entry found for this version. Please update the CHANGELOG manually." + else + echo "✅ Extracted release notes for version $VERSION" >&2 + NOTES="**Environment:** $ENV +**Tag:** $TAG_NAME + +## What is new in this release + +$CHANGELOG_CONTENT" + fi +else + echo "⚠️ Warning: CHANGELOG not found at $CHANGELOG_PATH" >&2 + NOTES="**Environment:** $ENV +**Tag:** $TAG_NAME + +## What is new in this release + +⚠️ CHANGELOG file not found at $CHANGELOG_PATH" +fi + +# Save to output file +echo "$NOTES" > "$OUTPUT_FILE" +echo "✅ Release notes saved to $OUTPUT_FILE" >&2 diff --git a/.github/scripts/extract-version.sh b/.github/scripts/extract-version.sh new file mode 100755 index 00000000..51e5b031 --- /dev/null +++ b/.github/scripts/extract-version.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Extract version from version file for products +# Usage: ./extract-version.sh +# app: worker-mobile-app or client-mobile-app + +set -e + +APP=$1 + +if [ -z "$APP" ]; then + echo "❌ Error: App parameter required (worker-mobile-app or client-mobile-app)" >&2 + exit 1 +fi + +# Determine pubspec path +if [ "$APP" = "worker-mobile-app" ]; then + PUBSPEC_PATH="apps/mobile/apps/staff/pubspec.yaml" + APP_NAME="Staff Product (Worker)" +else + PUBSPEC_PATH="apps/mobile/apps/client/pubspec.yaml" + APP_NAME="Client Product" +fi + +# Check if pubspec exists +if [ ! -f "$PUBSPEC_PATH" ]; then + echo "❌ Error: pubspec.yaml not found at $PUBSPEC_PATH" >&2 + echo "📁 Current directory: $(pwd)" >&2 + echo "📂 Directory contents:" >&2 + ls -la apps/mobile/apps/ 2>&1 | head -20 >&2 + exit 1 +fi + +# Extract version (format: X.Y.Z+buildNumber or X.Y.Z-suffix) +VERSION_LINE=$(grep "^version:" "$PUBSPEC_PATH") +if [ -z "$VERSION_LINE" ]; then + echo "❌ Error: Could not find version in $PUBSPEC_PATH" >&2 + exit 1 +fi + +# Extract full version including suffix/build number +VERSION=$(echo "$VERSION_LINE" | sed 's/version: *//' | tr -d ' ') + +# Validate version format (X.Y.Z with optional +build or -suffix) +# Use grep for better portability across different bash versions +if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(\+[a-zA-Z0-9]+|-[a-zA-Z0-9]+)?$'; then + echo "❌ Error: Invalid version format in pubspec.yaml: $VERSION" >&2 + echo "Expected format: X.Y.Z, X.Y.Z+build, or X.Y.Z-suffix (e.g., 0.1.0, 0.1.0+12, 0.1.0-m3)" >&2 + exit 1 +fi + +echo "✅ Extracted version from $PUBSPEC_PATH: $VERSION" >&2 +echo "$VERSION" diff --git a/.github/scripts/generate-tag-name.sh b/.github/scripts/generate-tag-name.sh new file mode 100755 index 00000000..8376a217 --- /dev/null +++ b/.github/scripts/generate-tag-name.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Generate tag name for product release +# Usage: ./generate-tag-name.sh + +set -e + +APP=$1 +ENV=$2 +VERSION=$3 + +if [ -z "$APP" ] || [ -z "$ENV" ] || [ -z "$VERSION" ]; then + echo "❌ Error: Missing required parameters" >&2 + echo "Usage: ./generate-tag-name.sh " >&2 + exit 1 +fi + +# Strip -mobile-app suffix from app name for cleaner tag names +# worker-mobile-app -> worker, client-mobile-app -> client +APP_TAG=$(echo "$APP" | sed 's/-mobile-app$//') + +TAG_NAME="krow-withus-${APP_TAG}-mobile/${ENV}-v${VERSION}" +echo "$TAG_NAME" diff --git a/.github/scripts/setup-apk-signing.sh b/.github/scripts/setup-apk-signing.sh new file mode 100755 index 00000000..ce93e1d8 --- /dev/null +++ b/.github/scripts/setup-apk-signing.sh @@ -0,0 +1,106 @@ +#!/bin/bash + +# ============================================================================= +# Setup APK Signing for GitHub Actions +# ============================================================================= +# This script configures Android APK signing by decoding keystores from +# GitHub Secrets and setting up environment variables for build.gradle.kts +# +# Usage: +# ./setup-apk-signing.sh +# +# Arguments: +# app - worker-mobile-app or client-mobile-app +# environment - dev, stage, or prod +# temp_dir - Temporary directory for keystore files (e.g., ${{ runner.temp }}) +# +# Environment Variables (must be set): +# WORKER_KEYSTORE_DEV_BASE64, WORKER_KEYSTORE_STAGING_BASE64, WORKER_KEYSTORE_PROD_BASE64 +# WORKER_KEYSTORE_PASSWORD_DEV, WORKER_KEYSTORE_PASSWORD_STAGING, WORKER_KEYSTORE_PASSWORD_PROD +# WORKER_KEY_ALIAS_DEV, WORKER_KEY_ALIAS_STAGING, WORKER_KEY_ALIAS_PROD +# WORKER_KEY_PASSWORD_DEV, WORKER_KEY_PASSWORD_STAGING, WORKER_KEY_PASSWORD_PROD +# CLIENT_KEYSTORE_DEV_BASE64, CLIENT_KEYSTORE_STAGING_BASE64, CLIENT_KEYSTORE_PROD_BASE64 +# CLIENT_KEYSTORE_PASSWORD_DEV, CLIENT_KEYSTORE_PASSWORD_STAGING, CLIENT_KEYSTORE_PASSWORD_PROD +# CLIENT_KEY_ALIAS_DEV, CLIENT_KEY_ALIAS_STAGING, CLIENT_KEY_ALIAS_PROD +# CLIENT_KEY_PASSWORD_DEV, CLIENT_KEY_PASSWORD_STAGING, CLIENT_KEY_PASSWORD_PROD +# ============================================================================= + +set -e + +APP="$1" +ENV="$2" +TEMP_DIR="$3" + +if [ -z "$APP" ] || [ -z "$ENV" ] || [ -z "$TEMP_DIR" ]; then + echo "❌ Error: Missing required arguments" >&2 + echo "Usage: $0 " >&2 + exit 1 +fi + +echo "🔐 Setting up Android signing for $APP in $ENV environment..." >&2 + +# Determine which keystore to use +if [ "$APP" = "worker-mobile-app" ]; then + APP_TYPE="WORKER" + APP_NAME="STAFF" # CodeMagic uses STAFF in env var names +else + APP_TYPE="CLIENT" + APP_NAME="CLIENT" +fi + +# Convert environment to uppercase for env var names +ENV_UPPER=$(echo "$ENV" | tr '[:lower:]' '[:upper:]') +if [ "$ENV_UPPER" = "STAGE" ]; then + ENV_UPPER="STAGING" # CodeMagic uses STAGING instead of STAGE +fi + +# Get the keystore secret name dynamically +KEYSTORE_BASE64_VAR="${APP_TYPE}_KEYSTORE_${ENV_UPPER}_BASE64" +KEYSTORE_PASSWORD_VAR="${APP_TYPE}_KEYSTORE_PASSWORD_${ENV_UPPER}" +KEY_ALIAS_VAR="${APP_TYPE}_KEY_ALIAS_${ENV_UPPER}" +KEY_PASSWORD_VAR="${APP_TYPE}_KEY_PASSWORD_${ENV_UPPER}" + +# Get values using indirect expansion +KEYSTORE_BASE64="${!KEYSTORE_BASE64_VAR}" +KEYSTORE_PASSWORD="${!KEYSTORE_PASSWORD_VAR}" +KEY_ALIAS="${!KEY_ALIAS_VAR}" +KEY_PASSWORD="${!KEY_PASSWORD_VAR}" + +# Check if secrets are configured +if [ -z "$KEYSTORE_BASE64" ]; then + echo "⚠️ WARNING: Keystore secret $KEYSTORE_BASE64_VAR is not configured!" >&2 + echo "⚠️ APK will be built UNSIGNED for $ENV environment." >&2 + echo "⚠️ Please configure GitHub Secrets as documented in docs/RELEASE/APK_SIGNING_SETUP.md" >&2 + exit 0 +fi + +# Create temporary directory for keystore +KEYSTORE_DIR="${TEMP_DIR}/keystores" +mkdir -p "$KEYSTORE_DIR" +KEYSTORE_PATH="$KEYSTORE_DIR/release.jks" + +# Decode keystore from base64 +echo "$KEYSTORE_BASE64" | base64 -d > "$KEYSTORE_PATH" + +if [ ! -f "$KEYSTORE_PATH" ]; then + echo "❌ Failed to decode keystore!" >&2 + exit 1 +fi + +echo "✅ Keystore decoded successfully" >&2 +echo "📦 Keystore size: $(ls -lh "$KEYSTORE_PATH" | awk '{print $5}')" >&2 + +# Export environment variables for build.gradle.kts +# Note: build.gradle.kts expects variables WITHOUT app suffix +echo "CI=true" >> $GITHUB_ENV +echo "CM_KEYSTORE_PATH=$KEYSTORE_PATH" >> $GITHUB_ENV +echo "CM_KEYSTORE_PASSWORD=$KEYSTORE_PASSWORD" >> $GITHUB_ENV +echo "CM_KEY_ALIAS=$KEY_ALIAS" >> $GITHUB_ENV +echo "CM_KEY_PASSWORD=$KEY_PASSWORD" >> $GITHUB_ENV + +echo "✅ Signing environment configured for $APP_NAME ($ENV environment)" >&2 +echo "🔑 Using key alias: $KEY_ALIAS" >&2 +echo "📝 Environment variables exported:" >&2 +echo " - CI=true" >&2 +echo " - CM_KEYSTORE_PATH=$KEYSTORE_PATH" >&2 +echo " - CM_KEY_ALIAS=$KEY_ALIAS" >&2 diff --git a/.github/scripts/setup-mobile-github-secrets.sh b/.github/scripts/setup-mobile-github-secrets.sh new file mode 100755 index 00000000..3645bb82 --- /dev/null +++ b/.github/scripts/setup-mobile-github-secrets.sh @@ -0,0 +1,262 @@ +#!/bin/bash + +# ============================================================================= +# GitHub Secrets Setup Helper +# ============================================================================= +# This script helps you configure GitHub Secrets for APK signing +# +# Usage: +# ./setup-mobile-github-secrets.sh +# +# Reference: docs/RELEASE/APK_SIGNING_SETUP.md +# ============================================================================= + +set -e + +REPO_ROOT=$(git rev-parse --show-toplevel) +cd "$REPO_ROOT" + +echo "🔐 GitHub Secrets Setup Helper for APK Signing" +echo "================================================" +echo "" + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Track successful secret generations +SECRETS_FOUND=0 +TOTAL_SECRETS=24 + +# ============================================================================= +# Helper Functions +# ============================================================================= + +print_secret_config() { + local app=$1 + local env=$2 + local keystore_path=$3 + local password=$4 + local alias=$5 + local key_password=$6 + + local app_upper=$(echo "$app" | tr '[:lower:]' '[:upper:]') + local env_upper=$(echo "$env" | tr '[:lower:]' '[:upper:]') + + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " ${app_upper} Mobile - ${env_upper} Environment" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + if [ -f "$keystore_path" ]; then + echo -e "${GREEN}✅ Keystore found:${NC} $keystore_path" + + # Show keystore info + echo "" + echo "📋 Keystore Information:" + keytool -list -v -keystore "$keystore_path" -storepass "$password" 2>/dev/null | head -n 15 || echo " (Use keytool to inspect)" + + # Generate base64 + echo "" + echo "📦 Base64 Encoded Keystore:" + echo "" + BASE64_OUTPUT=$(base64 -i "$keystore_path") + echo "$BASE64_OUTPUT" + echo "" + + echo "GitHub Secrets to create:" + echo "" + echo " ${app_upper}_KEYSTORE_${env_upper}_BASE64" + echo " ${app_upper}_KEYSTORE_PASSWORD_${env_upper} = $password" + echo " ${app_upper}_KEY_ALIAS_${env_upper} = $alias" + echo " ${app_upper}_KEY_PASSWORD_${env_upper} = $key_password" + echo "" + + # Increment success counter (4 secrets per keystore) + SECRETS_FOUND=$((SECRETS_FOUND + 4)) + + else + echo -e "${YELLOW}⚠️ Keystore not found:${NC} $keystore_path" + echo "" + echo "This keystore should be stored securely (CodeMagic or secure storage)." + echo "" + fi +} + +# ============================================================================= +# Worker Mobile (Staff App) +# ============================================================================= + +echo "" +echo "═══════════════════════════════════════════════════════" +echo " WORKER MOBILE (Staff App) Configuration" +echo "═══════════════════════════════════════════════════════" + +# DEV Environment +print_secret_config \ + "worker" \ + "dev" \ + "$REPO_ROOT/apps/mobile/apps/staff/android/app/krow_with_us_staff_dev.jks" \ + "krowwithus" \ + "krow_staff_dev" \ + "krowwithus" + +# STAGING Environment +print_secret_config \ + "worker" \ + "staging" \ + "$REPO_ROOT/keystores/krow_staff_staging.jks" \ + "YOUR_STAGING_PASSWORD" \ + "krow_staff_staging" \ + "YOUR_STAGING_KEY_PASSWORD" + +# PROD Environment +print_secret_config \ + "worker" \ + "prod" \ + "$REPO_ROOT/keystores/krow_staff_prod.jks" \ + "YOUR_PROD_PASSWORD" \ + "krow_staff_prod" \ + "YOUR_PROD_KEY_PASSWORD" + +# ============================================================================= +# Client Mobile +# ============================================================================= + +echo "" +echo "═══════════════════════════════════════════════════════" +echo " CLIENT MOBILE Configuration" +echo "═══════════════════════════════════════════════════════" + +# DEV Environment +print_secret_config \ + "client" \ + "dev" \ + "$REPO_ROOT/apps/mobile/apps/client/android/app/krow_with_us_client_dev.jks" \ + "krowwithus" \ + "krow_client_dev" \ + "krowwithus" + +# STAGING Environment +print_secret_config \ + "client" \ + "staging" \ + "$REPO_ROOT/keystores/krow_client_staging.jks" \ + "YOUR_STAGING_PASSWORD" \ + "krow_client_staging" \ + "YOUR_STAGING_KEY_PASSWORD" + +# PROD Environment +print_secret_config \ + "client" \ + "prod" \ + "$REPO_ROOT/keystores/krow_client_prod.jks" \ + "YOUR_PROD_PASSWORD" \ + "krow_client_prod" \ + "YOUR_PROD_KEY_PASSWORD" + +# ============================================================================= +# Summary +# ============================================================================= + +echo "" +echo "═══════════════════════════════════════════════════════" +echo " SUMMARY" +echo "═══════════════════════════════════════════════════════" +echo "" +echo "Total secrets needed: ${TOTAL_SECRETS}" +echo "Secrets successfully generated: ${SECRETS_FOUND}" +echo "" +echo " • 6 keystores (base64 encoded)" +echo " • 6 keystore passwords" +echo " • 6 key aliases" +echo " • 6 key passwords" +echo "" + +if [ $SECRETS_FOUND -gt 0 ]; then + echo "Generated secrets to add to GitHub:" + echo "" + + # Worker Dev Secrets + if [ -f "$REPO_ROOT/apps/mobile/apps/staff/android/app/krow_with_us_staff_dev.jks" ]; then + echo " ✅ WORKER_KEYSTORE_DEV_BASE64" + echo " $(base64 -i "$REPO_ROOT/apps/mobile/apps/staff/android/app/krow_with_us_staff_dev.jks")" + echo "" + echo " ✅ WORKER_KEYSTORE_PASSWORD_DEV" + echo " krowwithus" + echo "" + echo " ✅ WORKER_KEY_ALIAS_DEV" + echo " krow_staff_dev" + echo "" + echo " ✅ WORKER_KEY_PASSWORD_DEV" + echo " krowwithus" + echo "" + fi + + # Client Dev Secrets + if [ -f "$REPO_ROOT/apps/mobile/apps/client/android/app/krow_with_us_client_dev.jks" ]; then + echo " ✅ CLIENT_KEYSTORE_DEV_BASE64" + echo " $(base64 -i "$REPO_ROOT/apps/mobile/apps/client/android/app/krow_with_us_client_dev.jks")" + echo "" + echo " ✅ CLIENT_KEYSTORE_PASSWORD_DEV" + echo " krowwithus" + echo "" + echo " ✅ CLIENT_KEY_ALIAS_DEV" + echo " krow_client_dev" + echo "" + echo " ✅ CLIENT_KEY_PASSWORD_DEV" + echo " krowwithus" + echo "" + fi +fi + +if [ $SECRETS_FOUND -lt $TOTAL_SECRETS ]; then + echo "Missing secrets (keystores not found):" + echo "" + + if [ ! -f "$REPO_ROOT/keystores/krow_staff_staging.jks" ]; then + echo " ⚠️ WORKER_KEYSTORE_STAGING_BASE64" + echo " ⚠️ WORKER_KEYSTORE_PASSWORD_STAGING" + echo " ⚠️ WORKER_KEY_ALIAS_STAGING" + echo " ⚠️ WORKER_KEY_PASSWORD_STAGING" + fi + + if [ ! -f "$REPO_ROOT/keystores/krow_staff_prod.jks" ]; then + echo " ⚠️ WORKER_KEYSTORE_PROD_BASE64" + echo " ⚠️ WORKER_KEYSTORE_PASSWORD_PROD" + echo " ⚠️ WORKER_KEY_ALIAS_PROD" + echo " ⚠️ WORKER_KEY_PASSWORD_PROD" + fi + + if [ ! -f "$REPO_ROOT/keystores/krow_client_staging.jks" ]; then + echo " ⚠️ CLIENT_KEYSTORE_STAGING_BASE64" + echo " ⚠️ CLIENT_KEYSTORE_PASSWORD_STAGING" + echo " ⚠️ CLIENT_KEY_ALIAS_STAGING" + echo " ⚠️ CLIENT_KEY_PASSWORD_STAGING" + fi + + if [ ! -f "$REPO_ROOT/keystores/krow_client_prod.jks" ]; then + echo " ⚠️ CLIENT_KEYSTORE_PROD_BASE64" + echo " ⚠️ CLIENT_KEYSTORE_PASSWORD_PROD" + echo " ⚠️ CLIENT_KEY_ALIAS_PROD" + echo " ⚠️ CLIENT_KEY_PASSWORD_PROD" + fi + + echo "" + echo "Retrieve missing keystores from CodeMagic Team Settings or secure storage." +fi + +echo "" +echo "To configure GitHub Secrets:" +echo "" +echo " 1. Go to: https://github.com/Oloodi/krow-workforce/settings/secrets/actions" +echo " 2. Click 'New repository secret'" +echo " 3. Add each secret listed above" +echo "" +echo "For complete documentation, see:" +echo " docs/RELEASE/APK_SIGNING_SETUP.md" +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" diff --git a/.github/scripts/verify-apk-signature.sh b/.github/scripts/verify-apk-signature.sh new file mode 100755 index 00000000..eec7088a --- /dev/null +++ b/.github/scripts/verify-apk-signature.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +# ============================================================================= +# Verify APK Signature +# ============================================================================= +# This script verifies that an APK is properly signed and displays +# certificate information +# +# Usage: +# ./verify-apk-signature.sh +# +# Arguments: +# apk_path - Path to the APK file to verify +# ============================================================================= + +set -e + +APK_PATH="$1" + +if [ -z "$APK_PATH" ]; then + echo "❌ Error: Missing APK path" >&2 + echo "Usage: $0 " >&2 + exit 1 +fi + +if [ ! -f "$APK_PATH" ]; then + echo "❌ APK not found at: $APK_PATH" >&2 + exit 1 +fi + +echo "🔍 Verifying APK signature..." >&2 + +# Check if APK is signed +if jarsigner -verify -verbose "$APK_PATH" 2>&1 | grep -q "jar verified"; then + echo "✅ APK is properly signed!" >&2 + + # Extract certificate details + echo "" >&2 + echo "📜 Certificate Details:" >&2 + jarsigner -verify -verbose -certs "$APK_PATH" 2>&1 | grep -A 3 "X.509" || true + + # Get signer info + echo "" >&2 + echo "🔑 Signer Information:" >&2 + keytool -printcert -jarfile "$APK_PATH" | head -n 15 + +else + echo "⚠️ WARNING: APK signature verification failed or APK is unsigned!" >&2 + echo "" >&2 + echo "This may happen if:" >&2 + echo " 1. GitHub Secrets are not configured for this environment" >&2 + echo " 2. Keystore credentials are incorrect" >&2 + echo " 3. Build configuration didn't apply signing" >&2 + echo "" >&2 + echo "See: docs/RELEASE/APK_SIGNING_SETUP.md for setup instructions" >&2 + + # Don't fail the build, just warn + # exit 1 +fi diff --git a/.github/workflows/backend-foundation.yml b/.github/workflows/backend-foundation.yml index ac0ec0d8..5fcdcc74 100644 --- a/.github/workflows/backend-foundation.yml +++ b/.github/workflows/backend-foundation.yml @@ -2,6 +2,7 @@ name: Backend Foundation on: workflow_dispatch: # Manual trigger only — auto-triggers disabled (free plan) + workflow_dispatch: jobs: backend-foundation-makefile: diff --git a/.github/workflows/hotfix-branch-creation.yml b/.github/workflows/hotfix-branch-creation.yml new file mode 100644 index 00000000..0dcaed0f --- /dev/null +++ b/.github/workflows/hotfix-branch-creation.yml @@ -0,0 +1,332 @@ +name: 🚨 Hotfix Branch Creation + +on: + workflow_dispatch: + inputs: + app: + description: '📦 Product' + required: true + type: choice + options: + - worker-mobile-app + - client-mobile-app + tag: + description: '🏷️ Current Tag (e.g., krow-withus-worker-mobile/prod-v0.1.0 or dev/stage)' + required: true + type: string + issue_description: + description: '📝 Brief issue description' + required: true + type: string + +jobs: + create-hotfix-branch: + name: 🚨 Create Hotfix Branch + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: 📥 Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 🔍 Validate tag exists + id: validate_tag + run: | + TAG="${{ github.event.inputs.tag }}" + + if ! git rev-parse "$TAG" >/dev/null 2>&1; then + echo "❌ Error: Tag '$TAG' does not exist" + echo "Available tags:" + git tag -l "krow-withus-*-mobile/*" | tail -20 + exit 1 + fi + + echo "✅ Tag exists: $TAG" + + # Extract version from tag + VERSION=$(echo "$TAG" | grep -oP 'v\K[0-9]+\.[0-9]+\.[0-9]+' || echo "") + if [ -z "$VERSION" ]; then + echo "❌ Error: Could not extract version from tag" + exit 1 + fi + + echo "current_version=${VERSION}" >> $GITHUB_OUTPUT + echo "📌 Current version: $VERSION" + + - name: 🔢 Calculate hotfix version + id: hotfix_version + run: | + CURRENT="${{ steps.validate_tag.outputs.current_version }}" + + # Split version into parts + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" + + # Increment PATCH version + NEW_PATCH=$((PATCH + 1)) + HOTFIX_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}" + + echo "hotfix_version=${HOTFIX_VERSION}" >> $GITHUB_OUTPUT + echo "🆕 Hotfix version: $HOTFIX_VERSION" + + - name: 🌿 Generate branch name + id: branch + run: | + APP="${{ github.event.inputs.app }}" + VERSION="${{ steps.hotfix_version.outputs.hotfix_version }}" + + # Strip -mobile-app suffix for cleaner branch names + APP_CLEAN=$(echo "$APP" | sed 's/-mobile-app$//') + + BRANCH_NAME="hotfix/krow-withus-${APP_CLEAN}-mobile-v${VERSION}" + echo "branch_name=${BRANCH_NAME}" >> $GITHUB_OUTPUT + echo "🌿 Branch to create: $BRANCH_NAME" + + - name: 🔍 Check if hotfix branch already exists + run: | + BRANCH="${{ steps.branch.outputs.branch_name }}" + + if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then + echo "❌ Error: Branch $BRANCH already exists" + exit 1 + fi + + echo "✅ Branch does not exist, proceeding..." + + - name: 🌿 Create hotfix branch from tag + run: | + TAG="${{ github.event.inputs.tag }}" + BRANCH="${{ steps.branch.outputs.branch_name }}" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Checkout the tag + git checkout "$TAG" + + # Create new branch + git checkout -b "$BRANCH" + + echo "✅ Created branch $BRANCH from tag $TAG" + + - name: 📝 Update version files + id: update_versions + run: | + APP="${{ github.event.inputs.app }}" + HOTFIX_VERSION="${{ steps.hotfix_version.outputs.hotfix_version }}" + + if [ "$APP" = "worker-mobile-app" ]; then + PUBSPEC_PATH="apps/mobile/apps/staff/pubspec.yaml" + CHANGELOG_PATH="apps/mobile/apps/staff/CHANGELOG.md" + APP_NAME="Staff Product" + else + PUBSPEC_PATH="apps/mobile/apps/client/pubspec.yaml" + CHANGELOG_PATH="apps/mobile/apps/client/CHANGELOG.md" + APP_NAME="Client Product" + fi + + # Update pubspec.yaml version + if [ -f "$PUBSPEC_PATH" ]; then + # Extract current version and build number + CURRENT_VERSION_LINE=$(grep "^version:" "$PUBSPEC_PATH") + CURRENT_BUILD=$(echo "$CURRENT_VERSION_LINE" | grep -oP '\+\K[0-9]+' || echo "1") + NEW_BUILD=$((CURRENT_BUILD + 1)) + + # Update version line + sed -i "s/^version:.*/version: ${HOTFIX_VERSION}+${NEW_BUILD}/" "$PUBSPEC_PATH" + + echo "✅ Updated $PUBSPEC_PATH to ${HOTFIX_VERSION}+${NEW_BUILD}" + echo "updated_files=true" >> $GITHUB_OUTPUT + else + echo "⚠️ Warning: $PUBSPEC_PATH not found" + echo "updated_files=false" >> $GITHUB_OUTPUT + fi + + - name: 📋 Add CHANGELOG entry + run: | + APP="${{ github.event.inputs.app }}" + HOTFIX_VERSION="${{ steps.hotfix_version.outputs.hotfix_version }}" + ISSUE="${{ github.event.inputs.issue_description }}" + + if [ "$APP" = "worker-mobile-app" ]; then + CHANGELOG_PATH="apps/mobile/apps/staff/CHANGELOG.md" + APP_NAME="Staff Product" + else + CHANGELOG_PATH="apps/mobile/apps/client/CHANGELOG.md" + APP_NAME="Client Product" + fi + + if [ -f "$CHANGELOG_PATH" ]; then + DATE=$(date +%Y-%m-%d) + + # Extract title and body + TITLE=$(head -n 1 "$CHANGELOG_PATH") + BODY=$(tail -n +2 "$CHANGELOG_PATH") + + # Rebuild CHANGELOG with hotfix entry + echo "$TITLE" > "$CHANGELOG_PATH" + echo "" >> "$CHANGELOG_PATH" + echo "## [${HOTFIX_VERSION}] - ${DATE} - HOTFIX" >> "$CHANGELOG_PATH" + echo "" >> "$CHANGELOG_PATH" + echo "### Fixed" >> "$CHANGELOG_PATH" + echo "- ${ISSUE}" >> "$CHANGELOG_PATH" + echo "" >> "$CHANGELOG_PATH" + echo "---" >> "$CHANGELOG_PATH" + echo "" >> "$CHANGELOG_PATH" + echo "$BODY" >> "$CHANGELOG_PATH" + + echo "✅ Added CHANGELOG entry for hotfix $HOTFIX_VERSION" + else + echo "⚠️ Warning: $CHANGELOG_PATH not found" + fi + + - name: 💾 Commit version changes + run: | + HOTFIX_VERSION="${{ steps.hotfix_version.outputs.hotfix_version }}" + ISSUE="${{ github.event.inputs.issue_description }}" + + git add -A + git commit -m "chore: prepare hotfix v${HOTFIX_VERSION} + +HOTFIX: ${ISSUE} + +- Bump version to ${HOTFIX_VERSION} +- Add CHANGELOG entry +- Ready for bug fix commits + +From tag: ${{ github.event.inputs.tag }}" + + echo "✅ Committed version changes" + + - name: 🚀 Push hotfix branch + run: | + BRANCH="${{ steps.branch.outputs.branch_name }}" + + git push origin "$BRANCH" + + echo "✅ Pushed branch: $BRANCH" + + - name: 📄 Create Pull Request + id: create_pr + env: + GH_TOKEN: ${{ github.token }} + run: | + BRANCH="${{ steps.branch.outputs.branch_name }}" + HOTFIX_VERSION="${{ steps.hotfix_version.outputs.hotfix_version }}" + ISSUE="${{ github.event.inputs.issue_description }}" + APP="${{ github.event.inputs.app }}" + + # Strip -mobile-app suffix for cleaner tag names + APP_CLEAN=$(echo "$APP" | sed 's/-mobile-app$//') + + if [ "$APP" = "worker-mobile-app" ]; then + APP_DISPLAY="Worker Product" + else + APP_DISPLAY="Client Product" + fi + + PR_TITLE="🚨 HOTFIX: ${APP_DISPLAY} v${HOTFIX_VERSION} - ${ISSUE}" + + PR_BODY="## 🚨 HOTFIX - URGENT FIX + +**App:** ${APP_DISPLAY} +**Version:** ${HOTFIX_VERSION} +**From:** \`${{ github.event.inputs.tag }}\` + +### Issue +${ISSUE} + +### Impact + + +### Solution + + +### Testing + + +--- + +## ⚠️ Hotfix Process + +1. ✅ Hotfix branch created +2. ⏳ **NEXT:** Make your bug fix commits to this branch +3. ⏳ Test the fix locally +4. ⏳ Request expedited review (< 15 minutes) +5. ⏳ Merge to main and create production tag + +### To add your fix: +\`\`\`bash +git checkout $BRANCH +# Make your changes +git commit -m \"fix: [description]\" +git push origin $BRANCH +\`\`\` + +### After merging: +\`\`\`bash +# Tag and release +git checkout main +git pull origin main +git tag -a krow-withus-${APP_CLEAN}-mobile/prod-v${HOTFIX_VERSION} -m \"HOTFIX: ${ISSUE}\" +git push origin krow-withus-${APP_CLEAN}-mobile/prod-v${HOTFIX_VERSION} +\`\`\` + +--- + +**Ref:** [Hotfix Process Documentation](../docs/release/HOTFIX_PROCESS.md)" + + # Create PR + PR_URL=$(gh pr create \ + --base main \ + --head "$BRANCH" \ + --title "$PR_TITLE" \ + --body "$PR_BODY" \ + --label "hotfix,urgent,production") + + echo "pr_url=${PR_URL}" >> $GITHUB_OUTPUT + echo "✅ Pull Request created: $PR_URL" + + - name: 📊 Hotfix Summary + run: | + # Strip -mobile-app suffix for cleaner tag names + APP_CLEAN=$(echo "${{ github.event.inputs.app }}" | sed 's/-mobile-app$//') + + echo "## 🚨 Hotfix Branch Created" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**App:** ${{ github.event.inputs.app }}" >> $GITHUB_STEP_SUMMARY + echo "**Issue:** ${{ github.event.inputs.issue_description }}" >> $GITHUB_STEP_SUMMARY + echo "**From Tag:** \`${{ github.event.inputs.tag }}\`" >> $GITHUB_STEP_SUMMARY + echo "**Current Version:** ${{ steps.validate_tag.outputs.current_version }}" >> $GITHUB_STEP_SUMMARY + echo "**Hotfix Version:** ${{ steps.hotfix_version.outputs.hotfix_version }}" >> $GITHUB_STEP_SUMMARY + echo "**Branch:** \`${{ steps.branch.outputs.branch_name }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### 🔧 Next Steps" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "1. **Checkout the hotfix branch:**" >> $GITHUB_STEP_SUMMARY + echo " \`\`\`bash" >> $GITHUB_STEP_SUMMARY + echo " git fetch origin" >> $GITHUB_STEP_SUMMARY + echo " git checkout ${{ steps.branch.outputs.branch_name }}" >> $GITHUB_STEP_SUMMARY + echo " \`\`\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "2. **Make your bug fix(es)** - Keep changes minimal!" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "3. **Test locally** - Verify the fix works" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "4. **Request expedited review** - Target < 15 minutes" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "5. **Merge PR and create production tag:**" >> $GITHUB_STEP_SUMMARY + echo " \`\`\`bash" >> $GITHUB_STEP_SUMMARY + echo " git checkout main" >> $GITHUB_STEP_SUMMARY + echo " git pull origin main" >> $GITHUB_STEP_SUMMARY + echo " git tag -a krow-withus-${APP_CLEAN}-mobile/prod-v${{ steps.hotfix_version.outputs.hotfix_version }} -m \"HOTFIX: ${{ github.event.inputs.issue_description }}\"" >> $GITHUB_STEP_SUMMARY + echo " git push origin krow-withus-${APP_CLEAN}-mobile/prod-v${{ steps.hotfix_version.outputs.hotfix_version }}" >> $GITHUB_STEP_SUMMARY + echo " \`\`\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [ -n "${{ steps.create_pr.outputs.pr_url }}" ]; then + echo "**Pull Request:** ${{ steps.create_pr.outputs.pr_url }}" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml index 95306e16..4729463d 100644 --- a/.github/workflows/mobile-ci.yml +++ b/.github/workflows/mobile-ci.yml @@ -2,6 +2,7 @@ name: Mobile CI on: workflow_dispatch: # Manual trigger only — auto-triggers disabled (free plan) + workflow_dispatch: jobs: detect-changes: diff --git a/.github/workflows/product-release.yml b/.github/workflows/product-release.yml new file mode 100644 index 00000000..7e72e4ec --- /dev/null +++ b/.github/workflows/product-release.yml @@ -0,0 +1,289 @@ +name: 📦 Product Release + +on: + workflow_dispatch: + inputs: + app: + description: '📦 Product' + required: true + type: choice + options: + - worker-mobile-app + - client-mobile-app + environment: + description: '🌍 Environment' + required: true + type: choice + options: + - dev + - stage + - prod + create_github_release: + description: '📦 Create GitHub Release' + required: true + type: boolean + default: true + prerelease: + description: '🔖 Mark as Pre-release' + required: false + type: boolean + default: false + +jobs: + validate-and-create-release: + name: 🚀 Create Product Release + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + version: ${{ steps.version.outputs.version }} + tag_name: ${{ steps.tag.outputs.tag_name }} + + steps: + - name: 📥 Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 🏃🏾‍♂️ Make scripts executable + run: | + chmod +x .github/scripts/*.sh + echo "✅ Scripts are now executable" + + - name: 📖 Extract version from version file + id: version + run: | + VERSION=$(.github/scripts/extract-version.sh "${{ github.event.inputs.app }}") + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "📌 Extracted version: ${VERSION}" + + - name: 🏷️ Generate tag name + id: tag + run: | + TAG_NAME=$(.github/scripts/generate-tag-name.sh \ + "${{ github.event.inputs.app }}" \ + "${{ github.event.inputs.environment }}" \ + "${{ steps.version.outputs.version }}") + echo "tag_name=${TAG_NAME}" >> $GITHUB_OUTPUT + echo "🎯 Tag to create: ${TAG_NAME}" + + - name: 🔍 Check if tag already exists + run: | + TAG_NAME="${{ steps.tag.outputs.tag_name }}" + if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then + echo "❌ Error: Tag $TAG_NAME already exists" + echo "💡 Tip: Update the version in the version file before creating a new release" + exit 1 + fi + echo "✅ Tag does not exist, proceeding..." + + - name: 📋 Extract release notes from CHANGELOG + id: release_notes + run: | + .github/scripts/extract-release-notes.sh \ + "${{ github.event.inputs.app }}" \ + "${{ steps.version.outputs.version }}" \ + "${{ github.event.inputs.environment }}" \ + "${{ steps.tag.outputs.tag_name }}" \ + "/tmp/release_notes.md" + echo "notes_file=/tmp/release_notes.md" >> $GITHUB_OUTPUT + + - name: 🏷️ Create Git Tag + run: | + TAG_NAME="${{ steps.tag.outputs.tag_name }}" + APP="${{ github.event.inputs.app }}" + ENV="${{ github.event.inputs.environment }}" + VERSION="${{ steps.version.outputs.version }}" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git tag -a "$TAG_NAME" -m "🚀 Release ${APP} product ${VERSION} to ${ENV}" + git push origin "$TAG_NAME" + + echo "✅ Tag created and pushed: $TAG_NAME" + + - name: 📦 Create GitHub Release + if: ${{ github.event.inputs.create_github_release == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG_NAME="${{ steps.tag.outputs.tag_name }}" + APP="${{ github.event.inputs.app }}" + ENV="${{ github.event.inputs.environment }}" + VERSION="${{ steps.version.outputs.version }}" + + # Generate release title + if [ "$APP" = "worker-mobile-app" ]; then + APP_DISPLAY="Worker Mobile Application" + else + APP_DISPLAY="Client Mobile Application" + fi + + ENV_UPPER=$(echo "$ENV" | tr '[:lower:]' '[:upper:]') + RELEASE_NAME="Krow With Us - ${APP_DISPLAY} - ${ENV_UPPER} - v${VERSION}" + + echo "📦 Creating GitHub Release: $RELEASE_NAME" + + # Create release + if [ "${{ github.event.inputs.prerelease }}" = "true" ]; then + gh release create "$TAG_NAME" \ + --title "$RELEASE_NAME" \ + --notes-file "${{ steps.release_notes.outputs.notes_file }}" \ + --prerelease + echo "🔖 Pre-release created successfully" + else + gh release create "$TAG_NAME" \ + --title "$RELEASE_NAME" \ + --notes-file "${{ steps.release_notes.outputs.notes_file }}" + echo "✅ Release created successfully" + fi + + - name: 📊 Generate Release Summary + run: | + .github/scripts/create-release-summary.sh \ + "${{ github.event.inputs.app }}" \ + "${{ github.event.inputs.environment }}" \ + "${{ steps.version.outputs.version }}" \ + "${{ steps.tag.outputs.tag_name }}" + + build-mobile-artifacts: + name: 📱 Build Mobile APK + runs-on: ubuntu-latest + needs: validate-and-create-release + if: ${{ github.event.inputs.app == 'worker-mobile-app' || github.event.inputs.app == 'client-mobile-app' }} + permissions: + contents: write + + steps: + - name: 📥 Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 🟢 Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: 🔥 Install Firebase CLI + run: | + npm install -g firebase-tools + firebase --version + echo "ℹ️ Note: Firebase CLI installed for Data Connect SDK generation" + echo "ℹ️ If SDK generation fails, ensure Data Connect SDK files are committed to repo" + + - name: ☕ Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: 🐦 Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.38.x' + channel: 'stable' + cache: true + + - name: 🔧 Install Melos + run: | + dart pub global activate melos + echo "$HOME/.pub-cache/bin" >> $GITHUB_PATH + + - name: 📦 Install Dependencies + run: | + make mobile-install + + - name: 🔐 Setup APK Signing + env: + # Worker Mobile (Staff App) Secrets + WORKER_KEYSTORE_DEV_BASE64: ${{ secrets.WORKER_KEYSTORE_DEV_BASE64 }} + WORKER_KEYSTORE_STAGING_BASE64: ${{ secrets.WORKER_KEYSTORE_STAGING_BASE64 }} + WORKER_KEYSTORE_PROD_BASE64: ${{ secrets.WORKER_KEYSTORE_PROD_BASE64 }} + WORKER_KEYSTORE_PASSWORD_DEV: ${{ secrets.WORKER_KEYSTORE_PASSWORD_DEV }} + WORKER_KEYSTORE_PASSWORD_STAGING: ${{ secrets.WORKER_KEYSTORE_PASSWORD_STAGING }} + WORKER_KEYSTORE_PASSWORD_PROD: ${{ secrets.WORKER_KEYSTORE_PASSWORD_PROD }} + WORKER_KEY_ALIAS_DEV: ${{ secrets.WORKER_KEY_ALIAS_DEV }} + WORKER_KEY_ALIAS_STAGING: ${{ secrets.WORKER_KEY_ALIAS_STAGING }} + WORKER_KEY_ALIAS_PROD: ${{ secrets.WORKER_KEY_ALIAS_PROD }} + WORKER_KEY_PASSWORD_DEV: ${{ secrets.WORKER_KEY_PASSWORD_DEV }} + WORKER_KEY_PASSWORD_STAGING: ${{ secrets.WORKER_KEY_PASSWORD_STAGING }} + WORKER_KEY_PASSWORD_PROD: ${{ secrets.WORKER_KEY_PASSWORD_PROD }} + + # Client Mobile Secrets + CLIENT_KEYSTORE_DEV_BASE64: ${{ secrets.CLIENT_KEYSTORE_DEV_BASE64 }} + CLIENT_KEYSTORE_STAGING_BASE64: ${{ secrets.CLIENT_KEYSTORE_STAGING_BASE64 }} + CLIENT_KEYSTORE_PROD_BASE64: ${{ secrets.CLIENT_KEYSTORE_PROD_BASE64 }} + CLIENT_KEYSTORE_PASSWORD_DEV: ${{ secrets.CLIENT_KEYSTORE_PASSWORD_DEV }} + CLIENT_KEYSTORE_PASSWORD_STAGING: ${{ secrets.CLIENT_KEYSTORE_PASSWORD_STAGING }} + CLIENT_KEYSTORE_PASSWORD_PROD: ${{ secrets.CLIENT_KEYSTORE_PASSWORD_PROD }} + CLIENT_KEY_ALIAS_DEV: ${{ secrets.CLIENT_KEY_ALIAS_DEV }} + CLIENT_KEY_ALIAS_STAGING: ${{ secrets.CLIENT_KEY_ALIAS_STAGING }} + CLIENT_KEY_ALIAS_PROD: ${{ secrets.CLIENT_KEY_ALIAS_PROD }} + CLIENT_KEY_PASSWORD_DEV: ${{ secrets.CLIENT_KEY_PASSWORD_DEV }} + CLIENT_KEY_PASSWORD_STAGING: ${{ secrets.CLIENT_KEY_PASSWORD_STAGING }} + CLIENT_KEY_PASSWORD_PROD: ${{ secrets.CLIENT_KEY_PASSWORD_PROD }} + run: | + .github/scripts/setup-apk-signing.sh \ + "${{ github.event.inputs.app }}" \ + "${{ github.event.inputs.environment }}" \ + "${{ runner.temp }}" + + - name: 🏗️ Build APK + id: build_apk + run: | + APP="${{ github.event.inputs.app }}" + + if [ "$APP" = "worker-mobile-app" ]; then + echo "📱 Building Staff (Worker) APK..." + make mobile-staff-build PLATFORM=apk MODE=release + APP_NAME="staff" + else + echo "📱 Building Client APK..." + make mobile-client-build PLATFORM=apk MODE=release + APP_NAME="client" + fi + + # Find the generated APK (Flutter places it in build/app/outputs/flutter-apk/) + APK_PATH=$(find apps/mobile/apps/${APP_NAME}/build/app/outputs/flutter-apk -name "app-release.apk" 2>/dev/null | head -n 1) + + # Fallback to searching entire apps directory if not found + if [ -z "$APK_PATH" ]; then + APK_PATH=$(find apps/mobile/apps/${APP_NAME} -name "app-release.apk" | head -n 1) + fi + + if [ -z "$APK_PATH" ]; then + echo "❌ Error: APK not found!" + echo "Searched in apps/mobile/apps/${APP_NAME}/" + find apps/mobile/apps/${APP_NAME} -name "*.apk" || echo "No APK files found" + exit 1 + fi + + echo "✅ APK built successfully: $APK_PATH" + echo "app_name=${APP_NAME}" >> $GITHUB_OUTPUT + echo "apk_path=${APK_PATH}" >> $GITHUB_OUTPUT + + - name: ✅ Verify APK Signature + run: | + .github/scripts/verify-apk-signature.sh "${{ steps.build_apk.outputs.apk_path }}" + + - name: 📤 Upload APK as Artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ github.event.inputs.app }}-${{ needs.validate-and-create-release.outputs.version }}-${{ github.event.inputs.environment }} + path: apps/mobile/apps/${{ steps.build_apk.outputs.app_name }}/build/app/outputs/flutter-apk/app-release.apk + if-no-files-found: error + retention-days: 30 + + - name: 📦 Attach APK to GitHub Release + if: ${{ github.event.inputs.create_github_release == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + .github/scripts/attach-apk-to-release.sh \ + "${{ needs.validate-and-create-release.outputs.tag_name }}" \ + "${{ github.event.inputs.app }}" \ + "${{ steps.build_apk.outputs.app_name }}" \ + "${{ needs.validate-and-create-release.outputs.version }}" \ + "${{ github.event.inputs.environment }}" diff --git a/.gitignore b/.gitignore index 53393800..eb271963 100644 --- a/.gitignore +++ b/.gitignore @@ -187,9 +187,11 @@ krow-workforce-export-latest/ apps/mobile/packages/data_connect/lib/src/dataconnect_generated/ apps/web/src/dataconnect-generated/ +# Legacy mobile applications +apps/mobile/legacy/* + AGENTS.md -CLAUDE.md -GEMINI.md TASKS.md +CLAUDE.md \n# Android Signing (Secure)\n**.jks\n**key.properties diff --git a/.vscode/launch.json b/.vscode/launch.json index 437dd654..9205497b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,41 +1,127 @@ { "version": "0.2.0", "configurations": [ + // ===================== Client App ===================== { - "name": "Client (Dev) - Android", + "name": "Client [DEV] - Android", "request": "launch", "type": "dart", "program": "apps/mobile/apps/client/lib/main.dart", "args": [ + "--flavor", "dev", "--dart-define-from-file=${workspaceFolder}/apps/mobile/config.dev.json" ] }, { - "name": "Client (Dev) - iOS", + "name": "Client [DEV] - iOS", "request": "launch", "type": "dart", "program": "apps/mobile/apps/client/lib/main.dart", "args": [ + "--flavor", "dev", "--dart-define-from-file=${workspaceFolder}/apps/mobile/config.dev.json" ] }, { - "name": "Staff (Dev) - Android", + "name": "Client [STG] - Android", + "request": "launch", + "type": "dart", + "program": "apps/mobile/apps/client/lib/main.dart", + "args": [ + "--flavor", "stage", + "--dart-define-from-file=${workspaceFolder}/apps/mobile/config.stage.json" + ] + }, + { + "name": "Client [STG] - iOS", + "request": "launch", + "type": "dart", + "program": "apps/mobile/apps/client/lib/main.dart", + "args": [ + "--flavor", "stage", + "--dart-define-from-file=${workspaceFolder}/apps/mobile/config.stage.json" + ] + }, + { + "name": "Client [PROD] - Android", + "request": "launch", + "type": "dart", + "program": "apps/mobile/apps/client/lib/main.dart", + "args": [ + "--flavor", "prod", + "--dart-define-from-file=${workspaceFolder}/apps/mobile/config.prod.json" + ] + }, + { + "name": "Client [PROD] - iOS", + "request": "launch", + "type": "dart", + "program": "apps/mobile/apps/client/lib/main.dart", + "args": [ + "--flavor", "prod", + "--dart-define-from-file=${workspaceFolder}/apps/mobile/config.prod.json" + ] + }, + // ===================== Staff App ===================== + { + "name": "Staff [DEV] - Android", "request": "launch", "type": "dart", "program": "apps/mobile/apps/staff/lib/main.dart", "args": [ + "--flavor", "dev", "--dart-define-from-file=${workspaceFolder}/apps/mobile/config.dev.json" ] }, { - "name": "Staff (Dev) - iOS", + "name": "Staff [DEV] - iOS", "request": "launch", "type": "dart", "program": "apps/mobile/apps/staff/lib/main.dart", "args": [ + "--flavor", "dev", "--dart-define-from-file=${workspaceFolder}/apps/mobile/config.dev.json" ] + }, + { + "name": "Staff [STG] - Android", + "request": "launch", + "type": "dart", + "program": "apps/mobile/apps/staff/lib/main.dart", + "args": [ + "--flavor", "stage", + "--dart-define-from-file=${workspaceFolder}/apps/mobile/config.stage.json" + ] + }, + { + "name": "Staff [STG] - iOS", + "request": "launch", + "type": "dart", + "program": "apps/mobile/apps/staff/lib/main.dart", + "args": [ + "--flavor", "stage", + "--dart-define-from-file=${workspaceFolder}/apps/mobile/config.stage.json" + ] + }, + { + "name": "Staff [PROD] - Android", + "request": "launch", + "type": "dart", + "program": "apps/mobile/apps/staff/lib/main.dart", + "args": [ + "--flavor", "prod", + "--dart-define-from-file=${workspaceFolder}/apps/mobile/config.prod.json" + ] + }, + { + "name": "Staff [PROD] - iOS", + "request": "launch", + "type": "dart", + "program": "apps/mobile/apps/staff/lib/main.dart", + "args": [ + "--flavor", "prod", + "--dart-define-from-file=${workspaceFolder}/apps/mobile/config.prod.json" + ] } ] -} \ No newline at end of file +} diff --git a/BLOCKERS.md b/BLOCKERS.md deleted file mode 100644 index af8df57d..00000000 --- a/BLOCKERS.md +++ /dev/null @@ -1,57 +0,0 @@ -# Blockers - -## App -- Client application - -### Github issue -- https://github.com/Oloodi/krow-workforce/issues/210 -### Why this task is blocked: -- This task is currently blocked, mainly because client registration via social logins is blocked. To create a business, we require a business name, and with social sign-up we don’t have a screen to capture that information. Because of this, the flow cannot be completed. -- The best option, in my opinion, is to allow Google and Apple sign-in only for existing users, and not use them for new user registration. - -### Github issue -- https://github.com/Oloodi/krow-workforce/issues/257 -### Why this task is blocked: -- Although this page existed in the prototype, it was not connected to any other pages. In other words, there was no way to navigate to it from anywhere in the application. Therefore, this issue can be closed, as the page is not required in the main application. - -## App -- Staff application - -### Github issue -- https://github.com/Oloodi/krow-workforce/issues/249 -### Why this task is blocked: -- Although this page existed in the prototype, it was not connected to any other pages. In other words, there was no way to navigate to it from anywhere in the application. Therefore, this issue can be closed, as the page is not required in the main application. - -### Github issue -- https://github.com/Oloodi/krow-workforce/issues/262 -### Why this task is blocked: -- Although this page existed in the prototype, it was not connected to any other pages. In other words, there was no way to navigate to it from anywhere in the application. Therefore, this issue can be closed, as the page is not required in the main application. - -# Deviations - -## App -- Client Application - -### Github issue -- https://github.com/Oloodi/krow-workforce/issues/240 -### Deveations: -- In the web prototype, when creating an order, position role rates are displayed based on the selected vendor. This behavior was missing in the mobile prototype. Therefore, we added a dropdown to select the vendor and display the corresponding role rates based on that selection. - -# Points to considerate in the future -- client APP: - - Billing need to download a pdf of their invoice. - - On app launch, check whether there is an active session. If a valid session exists, skip the auth flow and navigate directly to Home, loading business account. - - Add an expiration time (TTL) to the session (store expiresAt / expiryTimestamp) and invalidate/clear the session when it has expired. - - Rapid order need IA to work, I think we need also to add a form as the webpage. -- Staff APP: - - On app launch, check whether there is an active session. If a valid session exists, skip the auth flow and navigate directly to Home, loading Staff account. - - Add an expiration time (TTL) to the session (store expiresAt / expiryTimestamp) and invalidate/clear the session when it has expired. - - For staffs Skills = Roles? thinking in the future for the smart assigned that need to know the roles of staff to assign. - -## App -- Staff Application - -### Github issue -- https://github.com/Oloodi/krow-workforce/issues/248 -### Deveations: -- Assumed that a worker can only have one shift per day. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..8b17176f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,149 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +KROW Workforce is a workforce management platform monorepo containing Flutter mobile apps, a React web dashboard, and Firebase backend services. + +## Repository Structure + +``` +apps/mobile/ # Flutter monorepo (Melos workspace) + apps/staff/ # Staff mobile app + apps/client/ # Client (business) mobile app + packages/ + design_system/ # Shared UI tokens & components + core/ # Cross-cutting concerns (mixins, extensions) + core_localization/# i18n via Slang + domain/ # Pure Dart entities & failures + data_connect/ # Firebase Data Connect adapter (connectors) + features/staff/ # Staff feature packages + features/client/ # Client feature packages +apps/web/ # React/Vite web dashboard (TypeScript, Tailwind, Redux Toolkit) +backend/ + dataconnect/ # Firebase Data Connect GraphQL schemas + core-api/ # Core business logic service + cloud-functions/ # Serverless functions +``` + +## Common Commands + +All commands use the root `Makefile` (composed from `makefiles/*.mk`). Run `make help` for the full list. + +### Mobile (Flutter) +```bash +make mobile-install # Bootstrap Melos workspace + generate SDK +make mobile-staff-dev-android # Run staff app (add DEVICE=android) +make mobile-client-dev-android # Run client app +make mobile-analyze # Lint (flutter analyze) +make mobile-test # Run tests +make test-e2e # Maestro E2E tests (both apps) +``` + +Single-package operations via Melos: +```bash +cd apps/mobile +melos run gen:l10n # Generate localization (Slang) +melos run gen:build # Run build_runner +melos run analyze:all # Analyze all packages +melos run test:all # Test all packages +``` + +### Web (React/Vite) +```bash +make web-install # npm install +make web-dev # Start dev server +make web-build # Production build +make web-lint # ESLint +make web-test # Vitest +``` + +### Backend (Data Connect) +```bash +make dataconnect-generate-sdk [ENV=dev] # Generate SDK +make dataconnect-deploy [ENV=dev] # Deploy schemas +make dataconnect-sync-full [ENV=dev] # Deploy + migrate + generate +``` + +## Mobile Architecture + +**Clean Architecture** with strict inward dependency flow: + +``` +Presentation (Pages, BLoCs, Widgets) + → Application (Use Cases) + → Domain (Entities, Repository Interfaces, Failures) + ← Data (Repository Implementations, Connectors) +``` + +### Key Patterns + +- **State management:** Flutter BLoC/Cubit. Register BLoCs with `i.add()` (transient), never `i.addSingleton()`. Use `BlocProvider.value()` for shared BLoCs. +- **DI & Routing:** Flutter Modular. Safe navigation via `safeNavigate()`, `safePush()`, `popSafe()`. Never use `Navigator.push()` directly. +- **Error handling in BLoCs:** Use `BlocErrorHandler` mixin with `_safeEmit()` to prevent StateError on disposed BLoCs. +- **Backend access:** All Data Connect calls go through the `data_connect` package's Connectors. Use `_service.run(() => connector.().execute())` for automatic auth/token management. +- **Session management:** `SessionHandlerMixin` + `SessionListener` widget. Initialized in `main.dart` with role-based config. +- **Localization:** All user-facing strings via `context.strings.` from `core_localization`. Error messages via `ErrorTranslator`. +- **Design system:** Use tokens from `UiColors`, `UiTypography`, `UiConstants`. Never hardcode colors, fonts, or spacing. + +### Feature Package Structure + +New features go in `apps/mobile/packages/features///`: +``` +lib/src/ + domain/repositories/ # Abstract interface classes + data/repositories_impl/ # Implementations using data_connect + application/ # Use cases (business logic) + presentation/ + blocs/ # BLoCs/Cubits + pages/ # Pages (prefer StatelessWidget) + widgets/ # Reusable widgets +``` + +### Critical Rules + +- Features must not import other features directly +- Business logic belongs in Use Cases, never in BLoCs or widgets +- Firebase packages (`firebase_auth`, `firebase_data_connect`) belong only in `data_connect` +- Don't add 3rd-party packages without checking `packages/core` first +- Generated code directories are excluded from analysis: `**/dataconnect_generated/**`, `**/*.g.dart`, `**/*.freezed.dart` + +## Code Generation + +- **Slang** (i18n): Input `lib/src/l10n/*.i18n.json` → Output `strings.g.dart` +- **build_runner**: Various generated files (`.g.dart`, `.freezed.dart`) +- **Firebase Data Connect**: Auto-generated SDK in `packages/data_connect/lib/src/dataconnect_generated/` + +## Naming Conventions (Dart) + +| Type | Convention | Example | +|------|-----------|---------| +| Files | `snake_case` | `user_profile_page.dart` | +| Classes | `PascalCase` | `UserProfilePage` | +| Interfaces | suffix `Interface` | `AuthRepositoryInterface` | +| Implementations | suffix `Impl` | `AuthRepositoryImpl` | + +## Key Documentation + +- `docs/MOBILE/00-agent-development-rules.md` — Non-negotiable architecture rules +- `docs/MOBILE/01-architecture-principles.md` — Clean architecture details +- `docs/MOBILE/02-design-system-usage.md` — Design system token usage +- `docs/MOBILE/03-data-connect-connectors-pattern.md` — Backend integration pattern +- `docs/MOBILE/05-release-process.md` — Release quick reference +- `docs/RELEASE/mobile-releases.md` — Complete release guide + +## Skills & Sub-Agents + +#### Skills +- The project has 4 specialized skills in `.claude/skills/` that provide deep domain knowledge. Invoke them and other global skills that you have when working in their domains. + +#### Sub-Agents +- The project has 4 sub-agents in `.claude/sub-agents/` that can be invoked for specific tasks. Invoke them and other global sub-agents that you have when working in their domains. + + +## CI/CD + +- `.github/workflows/mobile-ci.yml` — Mobile build & test on PR +- `.github/workflows/product-release.yml` — Automated versioning, tags, APK builds +- `.github/workflows/web-quality.yml` — Web linting & tests diff --git a/Makefile b/Makefile index 4a029884..98d82e42 100644 --- a/Makefile +++ b/Makefile @@ -91,10 +91,11 @@ help: @echo "" @echo " 🛠️ DEVELOPMENT TOOLS" @echo " ────────────────────────────────────────────────────────────────────" - @echo " make install-melos Install Melos globally (for mobile dev)" - @echo " make install-git-hooks Install git pre-push hook (protect main/dev)" - @echo " make sync-prototypes Sync prototypes from client-krow-poc repo" - @echo " make clean-branches Delete local branches (keeps main/dev/demo/**/protected)" + @echo " make install-melos Install Melos globally (for mobile dev)" + @echo " make install-git-hooks Install git pre-push hook (protect main/dev)" + @echo " make sync-prototypes Sync prototypes from client-krow-poc repo" + @echo " make clean-branches Delete local branches (keeps main/dev/demo/**/protected)" + @echo " make setup-mobile-ci-secrets Setup GitHub Secrets for mobile APK signing (CI/CD)" @echo "" @echo " ℹ️ HELP" @echo " ────────────────────────────────────────────────────────────────────" diff --git a/README.md b/README.md index 597a8a4b..3d74c2f0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,17 @@ +

+ KROW Logo +

+ # KROW Workforce Monorepo KROW is a comprehensive workforce management platform designed to streamline operations for events, hospitality, and enterprise staffing. This monorepo contains all components of the ecosystem, from the data layer to the user-facing applications. +## 📍 Current Status + +**Latest Milestone:** M4 (Released: March 5, 2026) +- ✅ Staff Mobile App: [v0.0.1-m4](https://github.com/Oloodi/krow-workforce/releases/tag/krow-withus-worker-mobile%2Fdev-v0.0.1-m4) +- ✅ Client Mobile App: [v0.0.1-m4](https://github.com/Oloodi/krow-workforce/releases/tag/krow-withus-client-mobile%2Fdev-v0.0.1-m4) + ## 🚀 Repository Structure ### 📦 Apps (`/apps`) @@ -26,6 +36,7 @@ Tools and resources for the development and operations team: - **`/makefiles`**: Modularized `Makefile` logic for project automation. - **`/scripts`**: Automation scripts (security, hachage, environment setup). - **`/firebase`**: Global Firebase configuration (Firestore/Storage rules). +- **`/.github`**: GitHub Actions workflows for CI/CD and release automation. ## 🛠️ Tech Stack - **Frontend:** React (Vite) @@ -58,17 +69,56 @@ This project uses a modular `Makefile` for all common tasks. make launchpad-dev ``` +5. **Mobile app development:** + ```bash + make mobile-install + make mobile-client-dev-android [DEVICE=android] + make mobile-staff-dev-android [DEVICE=android] + ``` + +## 🚀 Release Process + +### Mobile App Releases + +We use GitHub Actions for automated mobile releases: + +- **Standard Release**: Trigger [Product Release workflow](https://github.com/Oloodi/krow-workforce/actions/workflows/product-release.yml) + - Auto-extracts version from `pubspec.yaml` + - Creates Git tags: `krow-withus--mobile/-vX.Y.Z` + - Generates GitHub Release with CHANGELOG + - Builds and signs APK (dev/stage/prod keystores) + +- **Hotfix Release**: Trigger [Hotfix Branch Creation workflow](https://github.com/Oloodi/krow-workforce/actions/workflows/hotfix-branch-creation.yml) + - Auto-increments PATCH version + - Updates `pubspec.yaml` and `CHANGELOG.md` + - Creates PR with fix instructions + +**See:** [Mobile Release Documentation](./docs/RELEASE/mobile-releases.md) for complete guide. + ## 📚 Documentation + +### Core Documentation - **[00-vision.md](./docs/00-vision.md)**: Project objectives and guiding principles. - **[01-backend-api-specification.md](./docs/01-backend-api-specification.md)**: (Legacy) Reference for data schemas. - **[02-codemagic-env-vars.md](./docs/02-codemagic-env-vars.md)**: Guide for CI/CD environment variables. - **[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. +- **[05-project-onboarding-master.md](./docs/05-project-onboarding-master.md)**: Comprehensive onboarding guide and project overview. ### Mobile Development Documentation +- **[MOBILE/00-agent-development-rules.md](./docs/MOBILE/00-agent-development-rules.md)**: Rules and best practices for mobile development. - **[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. +- **[MOBILE/03-data-connect-connectors-pattern.md](./docs/MOBILE/03-data-connect-connectors-pattern.md)**: Data Connect integration patterns. +- **[MOBILE/04-use-case-completion-audit.md](./docs/MOBILE/04-use-case-completion-audit.md)**: Feature implementation status and audit. +- **[MOBILE/05-release-process.md](./docs/MOBILE/05-release-process.md)**: Mobile app release process (quick reference). + +### Release Documentation +- **[RELEASE/mobile-releases.md](./docs/RELEASE/mobile-releases.md)**: Comprehensive mobile release guide with versioning, CHANGELOGs, GitHub Actions workflows, and APK signing. + +### CHANGELOGs +- **[Staff Mobile CHANGELOG](./apps/mobile/apps/staff/CHANGELOG.md)**: Staff app release history (M3, M4). +- **[Client Mobile CHANGELOG](./apps/mobile/apps/client/CHANGELOG.md)**: Client app release history (M3, M4). ## 🤝 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/NEXT_SPRINT_TASKS.md b/apps/mobile/NEXT_SPRINT_TASKS.md deleted file mode 100644 index 01b839e6..00000000 --- a/apps/mobile/NEXT_SPRINT_TASKS.md +++ /dev/null @@ -1,175 +0,0 @@ -## Recommended tasks for the next sprint - - -* In the mobile applications, since the structure is now finalized (at least for the existing features), we need to **strictly follow best practices while coding**: - * Break down large widgets into **smaller, reusable widgets** - * Add **doc comments** where necessary to improve readability and maintainability - * **Remove overly complicated or unnecessary logic** introduced by AI and simplify where possible -* Improvement points -- apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart - - Fix the location field in CoverageShiftRole to use the correct fallback logic. - - line 125 remove redundant location values. - -- Change the name of the dataconnect connector replacing the "ExampleConnecter" with "KrowConnecter" - -- ` final String status;` in `OrderItem` make it an enum. -- /// Date of the shift (ISO format). - final String date; make this in the DateTime format instead of string. - -- in `view_orders_cubit.dart` combine the logic of `_calculateUpNextCount ` and `_calculateTodayCount` into a single function that calculates both counts together to avoid redundant filtering of orders. -- In places api call in the when the api's not working we need to show a proper error message instead of just an empty list. -- pending should come first in the view order list. - - - - -- How to check if the shift can be accepted by a worker? - - if a shift is already accepted in that time - -- track minimum shift hours in the staff profile and show a warning if they try to apply for shifts that are below their minimum hours. - - this need to be added in the BE and also a FE validation (5 hrs). - -- Cannot cancel before 24 hours of the shift start time. If do we should charge for 4 hours of work for each shifts. - -- verify the order creation process in the client app. - - Vendor don't need to verify the order, when the order is created it should be automatically published. - - rethink the order status, we need to simplify it. - -- Validation layer - - Profile info - - emergency contact - - experiences - - attires - - there should be manual verification by the client even if the ai verification is passed. - - to track false positives and false negatives. - - certifications - - there should be manual verification by the client even if the ai verification is passed. - - to track false positives and false negatives. - - documents - - tax forms - -- How do we handle the current bank account verifcaiton in the current legacy application. -- We need have a show a list of clothing items in the staff app -> shift page. -- Template models for the pdf reports in the client and web apps. -- remove `any` type and replace it with the correct types in the codebase. - - -- What is the worker signup process - - - - - - - -# Developement Tasks - -## BE -- Shift acceptance validation by a worker - - How do we check if a shift can be accepted by a worker? - - if a shift is already accepted in that time - - we need to prevent accepting overlapping shifts. - - Make the alogrithm sclable which enables to add future rules for shift acceptance. - - This validation should be done in BE. -- Shift creation validation by a client - - Implement validation logic to ensure that shifts created by clients meet certain criteria - - This validation should be done in BE. - - CURRENTLY only add a Soft check for minimum shift hours when creating an order by a client - - When a client is creating an order, we need to check if the shift hours are below the minimum hours set by the vendor. - - This validation should be done in BE and also a FE validation - - Current minimum hours is 5 hrs. - - Make the alogrithm sclable which enables to add future rules for shift acceptance. -- Cancellation policy enforcement - - Implement logic to prevent cancellations within 24 hours of shift start time. - - If a cancellation is attempted within this window - - We need to finalise the penalty for this cancellation. -- Documentation upload process - - Implement a secure and efficient process for workers to upload required documentation (e.g., certifications, tax forms). - - Ensure that the uploaded documents are properly stored and linked to the worker's profile. -- Documentation parsing - - Implement a system to parse and extract relevant information from uploaded documents (e.g., certifications, tax forms) for verification purposes. - - there should be manual verification by the client even if the ai verification is passed. -- Attire upload - - Implement a system for workers to upload images of their attire for verification purposes. -- Attire verification - - Implement a system to verify the uploaded attire images against the required dress code for shifts. - - there should be manual verification by the client even if the ai verification is passed. -- Shift that require "awaiting confirmation" status - - Implement logic to handle shifts that require "awaiting confirmation" status, where the worker needs to manually confirm the shift before it becomes active. -- Enable NFC-based clock-in and clock-out functionality for staff members, allowing them to easily record their attendance using NFC technology (BE tasks). -- Enable worker profile visibility, where the worker's can hide their profile from clients if they choose to, and implement the necessary logic to handle profile visibility settings (BE tasks). -- Rapid order parsing (voice and text) using AI, allowing clients to quickly create orders by simply describing their needs, and implementing the necessary logic to parse and interpret the client's input to create accurate orders (BE tasks) - - This is always mapped similar to one time order creation. - -## FE - -### Staff mobile application -- Show google maps location in the shift details page in the woker app. -- Add a requirment section in the shift details page in the worker app which shows the requirements for that shift. -- Attire screen - - Show the list of MUST HAVE attire items. - - Show the list of NICE TO HAVE attire items. - - Allow workers to upload images of their attire for verification purposes. - - Show the list of uploaded attire images in the worker profile. -- FAQ screen in the worker app. -- Privacy and Security screen in the worker app. - - Profile visbility setting - - Terms of services (For now use a generated one but we need to have a proper one for the launch) - - Privacy policy (For now use a generated one but we need to have a proper one for the launch) - -### Client mobile application -- Implement the remaining order types - - Rapid order creation using voicd and text input, allowing clients to quickly create orders by simply describing their needs, and implementing the necessary UI and logic to parse and interpret the client's input to create accurate orders (FE tasks). - - After parsing this should be populated in the screen similar to one time order creation screen where the client can make any necessary adjustments before finalising the order. - - This is always mapped similar to one time order creation as this only handles same day orders. - -# Research Tasks -- How do we validate the SSN number of a worker in the US? - - Research third-party services or APIs that provide SSN validation. - - Evaluate the cost, reliability, and ease of integration of these services. - - Plan the integration process and identify any potential challenges. -- How do we validate the bank account details of a worker in the US? - - Research third-party services or APIs that provide bank account validation. - - Evaluate the cost, reliability, and ease of integration of these services. - - Plan the integration process and identify any potential challenges. - - In the legacy application we are only using soft FE checks but we need to have a proper validation process. -- What are the payment platforms we want to integrate for processing payments to workers? - - Research popular payment platforms (e.g., Stripe, PayPal, Square) that support payouts to workers. - - Evaluate the cost, reliability, and ease of integration of these platforms. - - Plan the integration process and identify any potential challenges. -- Implement test cases for 2 features in the web dashboard to be run in the agent browser (https://agent-browser.dev/) - - Research how to implement test cases for web applications using the agent browser. - - -# Business Tasks -- Create a template models for the pdf reports in the client and web apps. -- How do we handle situations like - - If a worker is a no-show for a shift. - - If the certain shifts are not getting enough applicants. - - These situtations hevaly disadvantages the clients and we need to have a clear policy on how to handle these situations. -- Terms of service and privacy policy for the mobile applications. -- How to handle the data request from worker side. -- Having a discussion about rephrasing certain termeniologies in the application - - Meaning of the worker registration process, is it signup or onboarding, because we are not doing a proper signup process where the worker can create an account by themselves, instead we are doing an onboarding process where the worker needs to provide their phone number and then we create an account for them and send them the OTP to login, as they should be already attached to a vendor when they are providing their phone number, we can consider this as an onboarding process rather than a signup process. - - Meaning of the worker profile visibility, is it "profile visibility" or "availability status", because the worker is not making their profile completely invisible to the clients, instead they are just marking themselves as unavailable for work, so we can consider this as "availability status" rather than "profile visibility". - - -- Meaning of the the auto match ? - - Shouldn't we do this any way ? - - Is this only a marketing item ? - - -- Validation layer - - Profile info - - emergency contact - - experiences - - attires - - there should be manual verification by the client even if the ai verification is passed. - - to track false positives and false negatives. - - certifications - - there should be manual verification by the client even if the ai verification is passed. - - to track false positives and false negatives. - - documents - - tax forms - -- We need have a show a list of clothing items in the staff app -> shift page. diff --git a/apps/mobile/apps/client/CHANGELOG.md b/apps/mobile/apps/client/CHANGELOG.md new file mode 100644 index 00000000..c2e2e024 --- /dev/null +++ b/apps/mobile/apps/client/CHANGELOG.md @@ -0,0 +1,230 @@ +# Client Mobile App - Change Log + +## [v0.0.1-m3] - Milestone 3 - 2026-02-15 + +### Added - Authentication & Onboarding +- Business email and password authentication +- Client account registration +- Business onboarding flow +- Company information setup + +### Added - Home Dashboard +- Welcome screen with business name +- Coverage statistics for today: + - Coverage percentage + - Workers checked in vs needed + - Open positions count +- Late workers alerts with visual indicators +- Today's estimated labor cost +- Upcoming shifts section +- Quick action buttons: + - Create Order + - Hubs management + +### Added - Hub Management +- Hubs page accessible from settings +- Hub creation flow: + - Hub name input + - Address autocomplete with Google Maps Places API + - Hub creation confirmation +- Hubs list view showing all created hubs +- Hub card display with name, address, and tag ID + +### Added - Order Creation +- Orders tab in bottom navigation +- "+ Post" button to create new orders +- Order type selection screen: + - One-Time orders (implemented) +- One-Time Order creation form: + - Order name + - Date picker + - Hub selection + - Position management: + - Role selection + - Worker count + - Start/end time + - Shift duration calculation + - Cost estimation +- Order creation confirmation + +### Added - Order Management +- Orders list view with: + - Order cards showing date, location, time + - Worker count (filled/needed) + - Coverage percentage bar + - Status indicators (OPEN, FILLED, IN PROGRESS) +- Order details view: + - Event name and location + - Roles and worker requirements + - Clock in/out times + - Estimated cost + - Coverage percentage + - Map integration with directions + +### Added - Coverage Monitoring +- Coverage tab in bottom navigation +- Real-time worker status dashboard: + - Checked In (green indicator) + - En Route (yellow indicator) + - Late (red indicator) + - Not Arrived status +- Color-coded status badges +- Worker information cards +- Active shift monitoring + +### Added - Navigation +- Bottom navigation bar with tabs: + - Coverage + - Billing + - Home + - Orders + - Reports +- Settings menu accessible from home screen +- Back navigation handling + +### Added - Settings +- Settings page with options: + - Hubs management + - Profile editing + - Notifications preferences + - Log out + +### Technical Features +- Firebase authentication integration +- Data Connect backend integration +- Google Maps Places API for address autocomplete +- Real-time worker status tracking +- Cost calculation engine +- Coverage percentage calculations + +### Known Limitations +- Orders require hub assignment +- Currently supports one-time orders only +- Order approval flow not yet implemented +- RAPID, Recurring, and Permanent order types are placeholders + +--- + +## [v0.0.1-m4] - Milestone 4 - 2026-03-05 + +### Added - Enhanced Authentication & Session Management +- Authentication session persistence across app restarts +- Automatic login with valid session tokens +- Improved user experience with seamless session handling + +### Added - RAPID Order Creation (AI-Powered) +- Voice input for order creation with audio recording +- Text input for order description +- Multi-platform audio recording support (iOS/Android) +- AI transcription service for voice-to-text conversion +- AI parsing to generate order drafts from natural language +- Same-day order support for urgent coverage needs +- Populated order form matching one-time order structure +- Edit AI-generated order before submission +- Quick order creation workflow +- Audio file upload for transcription +- RAPID order verification page with refinements +- Hub and role matching for order creation + +### Added - Recurring Order Support +- Recurring order creation flow +- Schedule configuration interface +- Recurring patterns (daily, weekly, custom) +- Recurring order management + +### Added - Permanent Order Support +- Permanent order creation flow +- Long-term position setup +- Permanent order management + +### Added - Enhanced Order Management +- Hide edit icon for past or completed orders +- Updated Reorder modal supporting all order types: + - One-Time reorder + - RAPID reorder + - Recurring reorder + - Permanent reorder +- Reorder functionality with order type awareness +- Hub manager assignment to orders +- Cost center entity linking to hubs +- Completion review UI with: + - Actions summary + - Amount display + - Info sections + - Worker listing +- Invoice management improvements + +### Added - Comprehensive Reports System +- Reports page with AI-powered insights +- Three AI-generated insights on reports landing page +- Six report types: + 1. **Daily Ops Report**: Daily operational metrics and statistics + 2. **Spend Report**: Labor cost analysis and spend tracking + 3. **Coverage Report**: Shift coverage analytics and trends + 4. **No-Show Report**: Worker attendance and no-show tracking + 5. **Performance Report**: Worker performance metrics and ratings + 6. *(Reserved for future report type)* + +### Added - Hub Management Enhancements +- Dedicated hub details interface +- Detailed hub information view +- Hub editing page (separate interface) +- Enhanced hub navigation + +### Added - Home Dashboard Enhancements +- Reorder quick action button +### Added - Home Dashboard Enhancements +- Reorder quick action button for fast order duplication +- Insights quick action button for AI analytics +- Direct access to AI insights from home +- Refactored home widgets with SectionLayout: + - Today's shifts section with titles + - Tomorrow's shifts section + - Coverage widget improvements + - Live activity widget enhancements + - Spending widget updates +- Full-width dividers for better visual separation +- Improved dashboard widget organization + +### Improved - User Experience +- Better order type selection flow +- Enhanced order creation UX across all types +- Improved reports navigation +- Better hub management interface +- Bottom navigation bar show/hide based on route changes +- Enhanced navigation robustness with error handling +- Improved invoice page layout with reordered titles +- Session management improvements with proper role validation +- Enhanced settings page navigation flow +- Better amount widget styling in completion review + +### Fixed +- Client app crash issues resolved +- Shift booking status inconsistencies fixed +- Session navigation errors corrected +- Formatting and code clarity improvements across codebase + +### Technical Features +- Backend transaction support for order creation +- Order validation (minimum hours check) +- Shift creation validation +- 24-hour cancellation policy enforcement +- Enhanced backend reporting APIs +- AI insights generation system +- Core API integration: + - RAPID order transcription endpoints + - Order parsing services + - File upload with signed URLs + - LLM services +- ApiService with Dio for standardized API requests +- DataConnectService integration across all repositories +- Enhanced session management with SessionListener +- Role-based session handling + +### Known Limitations +- RAPID order parsing requires clear voice/text input +- AI insights require sufficient historical data +- Reports may have limited data in early usage +- PDF export for reports not yet implemented + +--- diff --git a/apps/mobile/apps/client/android/app/build.gradle.kts b/apps/mobile/apps/client/android/app/build.gradle.kts index f169e26c..837bc911 100644 --- a/apps/mobile/apps/client/android/app/build.gradle.kts +++ b/apps/mobile/apps/client/android/app/build.gradle.kts @@ -21,8 +21,20 @@ dartDefinesString.split(",").forEach { } } +// Load flavor-specific key properties: key.dev.properties, key.stage.properties, key.prod.properties +// The active flavor is resolved from the Gradle task name (e.g. assembleDevRelease -> dev) +fun resolveFlavorFromTask(): String { + val taskNames = gradle.startParameter.taskNames.joinToString(" ").lowercase() + return when { + taskNames.contains("prod") -> "prod" + taskNames.contains("stage") -> "stage" + else -> "dev" + } +} + +val activeFlavorForSigning = resolveFlavorFromTask() val keystoreProperties = Properties().apply { - val propertiesFile = rootProject.file("key.properties") + val propertiesFile = rootProject.file("key.${activeFlavorForSigning}.properties") if (propertiesFile.exists()) { load(propertiesFile.inputStream()) } @@ -43,28 +55,44 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.krowwithus.client" - // You can update the following values to match your application needs. + // applicationId is set per flavor below // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartEnvironmentVariables["GOOGLE_MAPS_API_KEY"] ?: "" + } - manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartEnvironmentVariables["GOOGLE_MAPS_API_KEY"] ?: "" + flavorDimensions += "environment" + productFlavors { + create("dev") { + dimension = "environment" + applicationId = "dev.krowwithus.client" + resValue("string", "app_name", "KROW With Us Business [DEV]") + } + create("stage") { + dimension = "environment" + applicationId = "stage.krowwithus.client" + resValue("string", "app_name", "KROW With Us Business [STG]") + } + create("prod") { + dimension = "environment" + applicationId = "prod.krowwithus.client" + resValue("string", "app_name", "KROW Client") + } } signingConfigs { create("release") { if (System.getenv()["CI"] == "true") { // CodeMagic CI environment - storeFile = file(System.getenv()["CM_KEYSTORE_PATH_CLIENT"] ?: "") - storePassword = System.getenv()["CM_KEYSTORE_PASSWORD_CLIENT"] - keyAlias = System.getenv()["CM_KEY_ALIAS_CLIENT"] - keyPassword = System.getenv()["CM_KEY_PASSWORD_CLIENT"] + storeFile = file(System.getenv()["CM_KEYSTORE_PATH"] ?: "") + storePassword = System.getenv()["CM_KEYSTORE_PASSWORD"] + keyAlias = System.getenv()["CM_KEY_ALIAS"] + keyPassword = System.getenv()["CM_KEY_PASSWORD"] } else { - // Local development environment + // Local development environment — loads from key..properties keyAlias = keystoreProperties["keyAlias"] as String? keyPassword = keystoreProperties["keyPassword"] as String? storeFile = keystoreProperties["storeFile"]?.let { file(it) } @@ -75,13 +103,25 @@ android { buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. signingConfig = signingConfigs.getByName("release") } } } +// Skip google-services processing for flavors whose google-services.json +// contains placeholder values (e.g. prod before the Firebase project exists). +// Once a real config is dropped in, the task automatically re-enables. +afterEvaluate { + tasks.matching { + it.name.startsWith("process") && it.name.endsWith("GoogleServices") + }.configureEach { + val taskFlavor = name.removePrefix("process").removeSuffix("GoogleServices") + .removeSuffix("Debug").removeSuffix("Release").lowercase() + val configFile = file("src/$taskFlavor/google-services.json") + enabled = configFile.exists() && configFile.readText().contains("\"mobilesdk_app_id\": \"1:") + } +} + flutter { source = "../.." } diff --git a/apps/mobile/apps/client/android/app/google-services.json b/apps/mobile/apps/client/android/app/src/dev/google-services.json similarity index 94% rename from apps/mobile/apps/client/android/app/google-services.json rename to apps/mobile/apps/client/android/app/src/dev/google-services.json index e7c91c27..ca0a39ea 100644 --- a/apps/mobile/apps/client/android/app/google-services.json +++ b/apps/mobile/apps/client/android/app/src/dev/google-services.json @@ -5,78 +5,6 @@ "storage_bucket": "krow-workforce-dev.firebasestorage.app" }, "client": [ - { - "client_info": { - "mobilesdk_app_id": "1:933560802882:android:edcddb83ea4bbb517757db", - "android_client_info": { - "package_name": "com.krow.app.business.dev" - } - }, - "oauth_client": [ - { - "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", - "client_type": 3 - } - ], - "api_key": [ - { - "current_key": "AIzaSyDBYhflhK6DThKnS7RM-9raKdvyKzLUjY4" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [ - { - "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", - "client_type": 3 - }, - { - "client_id": "933560802882-29olj9ku64jbe9h7flinha6hbi8qrluh.apps.googleusercontent.com", - "client_type": 2, - "ios_info": { - "bundle_id": "com.krowwithus.staff" - } - } - ] - } - } - }, - { - "client_info": { - "mobilesdk_app_id": "1:933560802882:android:d49b8c0f4d19e95e7757db", - "android_client_info": { - "package_name": "com.krow.app.staff.dev" - } - }, - "oauth_client": [ - { - "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", - "client_type": 3 - } - ], - "api_key": [ - { - "current_key": "AIzaSyDBYhflhK6DThKnS7RM-9raKdvyKzLUjY4" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [ - { - "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", - "client_type": 3 - }, - { - "client_id": "933560802882-29olj9ku64jbe9h7flinha6hbi8qrluh.apps.googleusercontent.com", - "client_type": 2, - "ios_info": { - "bundle_id": "com.krowwithus.staff" - } - } - ] - } - } - }, { "client_info": { "mobilesdk_app_id": "1:933560802882:android:da13569105659ead7757db", @@ -164,6 +92,78 @@ ] } } + }, + { + "client_info": { + "mobilesdk_app_id": "1:933560802882:android:1eb46251032273cb7757db", + "android_client_info": { + "package_name": "dev.krowwithus.client" + } + }, + "oauth_client": [ + { + "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyDBYhflhK6DThKnS7RM-9raKdvyKzLUjY4" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "933560802882-29olj9ku64jbe9h7flinha6hbi8qrluh.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "com.krowwithus.staff" + } + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:933560802882:android:ee100eab75b6b04c7757db", + "android_client_info": { + "package_name": "dev.krowwithus.staff" + } + }, + "oauth_client": [ + { + "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyDBYhflhK6DThKnS7RM-9raKdvyKzLUjY4" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "933560802882-29olj9ku64jbe9h7flinha6hbi8qrluh.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "com.krowwithus.staff" + } + } + ] + } + } } ], "configuration_version": "1" diff --git a/apps/mobile/apps/client/android/app/src/main/AndroidManifest.xml b/apps/mobile/apps/client/android/app/src/main/AndroidManifest.xml index 555727c2..9416b135 100644 --- a/apps/mobile/apps/client/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/apps/client/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/apps/client/ios/Runner.xcodeproj/xcshareddata/xcschemes/prod.xcscheme b/apps/mobile/apps/client/ios/Runner.xcodeproj/xcshareddata/xcschemes/prod.xcscheme new file mode 100644 index 00000000..0f874d80 --- /dev/null +++ b/apps/mobile/apps/client/ios/Runner.xcodeproj/xcshareddata/xcschemes/prod.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/apps/client/ios/Runner.xcodeproj/xcshareddata/xcschemes/stage.xcscheme b/apps/mobile/apps/client/ios/Runner.xcodeproj/xcshareddata/xcschemes/stage.xcscheme new file mode 100644 index 00000000..87c22c02 --- /dev/null +++ b/apps/mobile/apps/client/ios/Runner.xcodeproj/xcshareddata/xcschemes/stage.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/apps/client/ios/Runner/Info.plist b/apps/mobile/apps/client/ios/Runner/Info.plist index e67d5b5d..bdc600e2 100644 --- a/apps/mobile/apps/client/ios/Runner/Info.plist +++ b/apps/mobile/apps/client/ios/Runner/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - KROW With Us Client + $(APP_NAME) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -13,7 +13,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - KROW With Us Client + $(APP_NAME) CFBundlePackageType APPL CFBundleShortVersionString diff --git a/apps/mobile/apps/staff/ios/Runner/GoogleService-Info.plist b/apps/mobile/apps/client/ios/config/dev/GoogleService-Info.plist similarity index 79% rename from apps/mobile/apps/staff/ios/Runner/GoogleService-Info.plist rename to apps/mobile/apps/client/ios/config/dev/GoogleService-Info.plist index 7fc4d7e6..75f58041 100644 --- a/apps/mobile/apps/staff/ios/Runner/GoogleService-Info.plist +++ b/apps/mobile/apps/client/ios/config/dev/GoogleService-Info.plist @@ -3,9 +3,9 @@ CLIENT_ID - 933560802882-29olj9ku64jbe9h7flinha6hbi8qrluh.apps.googleusercontent.com + 933560802882-jpv087j5jenp1h63mc9ge51767s3l2ac.apps.googleusercontent.com REVERSED_CLIENT_ID - com.googleusercontent.apps.933560802882-29olj9ku64jbe9h7flinha6hbi8qrluh + com.googleusercontent.apps.933560802882-jpv087j5jenp1h63mc9ge51767s3l2ac ANDROID_CLIENT_ID 933560802882-fbqg2icq24bmci3f84evjrbth5huh87f.apps.googleusercontent.com API_KEY @@ -15,7 +15,7 @@ PLIST_VERSION 1 BUNDLE_ID - com.krowwithus.staff + dev.krowwithus.client PROJECT_ID krow-workforce-dev STORAGE_BUCKET @@ -31,6 +31,6 @@ IS_SIGNIN_ENABLED GOOGLE_APP_ID - 1:933560802882:ios:fa584205b356de937757db + 1:933560802882:ios:7e179dfdd1a8994c7757db \ No newline at end of file diff --git a/apps/mobile/apps/client/ios/config/stage/GoogleService-Info.plist b/apps/mobile/apps/client/ios/config/stage/GoogleService-Info.plist new file mode 100644 index 00000000..631c0d6c --- /dev/null +++ b/apps/mobile/apps/client/ios/config/stage/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyCgTXI3QhbEK3r4J5y7ek_6AxqhmR99QjY + GCM_SENDER_ID + 1032971403708 + PLIST_VERSION + 1 + BUNDLE_ID + stage.krowwithus.client + PROJECT_ID + krow-workforce-staging + STORAGE_BUCKET + krow-workforce-staging.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:1032971403708:ios:0ff547e80f5324ed356bb9 + + \ No newline at end of file diff --git a/apps/mobile/apps/client/ios/scripts/firebase-config.sh b/apps/mobile/apps/client/ios/scripts/firebase-config.sh new file mode 100755 index 00000000..b700a0ad --- /dev/null +++ b/apps/mobile/apps/client/ios/scripts/firebase-config.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Copy the correct GoogleService-Info.plist based on the build configuration. +# This script should be added as a "Run Script" build phase in Xcode, +# BEFORE the "Compile Sources" phase. +# +# The FLUTTER_FLAVOR environment variable is set by Flutter when building +# with --flavor. It maps to: dev, stage, prod. + +FLAVOR="${FLUTTER_FLAVOR:-dev}" +PLIST_SOURCE="${PROJECT_DIR}/config/${FLAVOR}/GoogleService-Info.plist" +PLIST_DEST="${PROJECT_DIR}/Runner/GoogleService-Info.plist" + +if [ ! -f "$PLIST_SOURCE" ]; then + echo "error: GoogleService-Info.plist not found for flavor '${FLAVOR}' at ${PLIST_SOURCE}" + exit 1 +fi + +echo "Copying GoogleService-Info.plist for flavor: ${FLAVOR}" +cp "${PLIST_SOURCE}" "${PLIST_DEST}" diff --git a/apps/mobile/apps/client/lib/firebase_options.dart b/apps/mobile/apps/client/lib/firebase_options.dart index f703aa10..20904852 100644 --- a/apps/mobile/apps/client/lib/firebase_options.dart +++ b/apps/mobile/apps/client/lib/firebase_options.dart @@ -1,44 +1,22 @@ -// File generated by FlutterFire CLI. - import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb, TargetPlatform; +import 'package:krow_core/core.dart'; -/// Default [FirebaseOptions] for use with your Firebase apps. +/// Environment-aware [FirebaseOptions] for the Client app. /// -/// Example: -/// ```dart -/// import 'firebase_options.dart'; -/// // ... -/// await Firebase.initializeApp( -/// options: DefaultFirebaseOptions.currentPlatform, -/// ); -/// ``` +/// Selects the correct Firebase configuration based on the compile-time +/// `ENV` dart define (dev, stage, prod). Defaults to dev. class DefaultFirebaseOptions { static FirebaseOptions get currentPlatform { if (kIsWeb) { - return web; + return _webOptions; } switch (defaultTargetPlatform) { case TargetPlatform.android: - return android; + return _androidOptions; case TargetPlatform.iOS: - return ios; - case TargetPlatform.macOS: - throw UnsupportedError( - 'DefaultFirebaseOptions have not been configured for macos - ' - 'you can reconfigure this by running the FlutterFire CLI again.', - ); - case TargetPlatform.windows: - throw UnsupportedError( - 'DefaultFirebaseOptions have not been configured for windows - ' - 'you can reconfigure this by running the FlutterFire CLI again.', - ); - case TargetPlatform.linux: - throw UnsupportedError( - 'DefaultFirebaseOptions have not been configured for linux - ' - 'you can reconfigure this by running the FlutterFire CLI again.', - ); + return _iosOptions; default: throw UnsupportedError( 'DefaultFirebaseOptions are not supported for this platform.', @@ -46,7 +24,65 @@ class DefaultFirebaseOptions { } } - static const FirebaseOptions web = FirebaseOptions( + static FirebaseOptions get _androidOptions { + switch (AppEnvironment.current) { + case AppEnvironment.dev: + return _devAndroid; + case AppEnvironment.stage: + return _stageAndroid; + case AppEnvironment.prod: + return _prodAndroid; + } + } + + static FirebaseOptions get _iosOptions { + switch (AppEnvironment.current) { + case AppEnvironment.dev: + return _devIos; + case AppEnvironment.stage: + return _stageIos; + case AppEnvironment.prod: + return _prodIos; + } + } + + static FirebaseOptions get _webOptions { + switch (AppEnvironment.current) { + case AppEnvironment.dev: + return _devWeb; + case AppEnvironment.stage: + return _stageWeb; + case AppEnvironment.prod: + return _prodWeb; + } + } + + // =========================================================================== + // DEV (krow-workforce-dev) + // =========================================================================== + + static const FirebaseOptions _devAndroid = FirebaseOptions( + apiKey: 'AIzaSyDBYhflhK6DThKnS7RM-9raKdvyKzLUjY4', + appId: '1:933560802882:android:1eb46251032273cb7757db', + messagingSenderId: '933560802882', + projectId: 'krow-workforce-dev', + storageBucket: 'krow-workforce-dev.firebasestorage.app', + ); + + static const FirebaseOptions _devIos = FirebaseOptions( + apiKey: 'AIzaSyDyEXkzZAWpXXe4dAesYaZflt5BEtMn9tA', + appId: '1:933560802882:ios:7e179dfdd1a8994c7757db', + messagingSenderId: '933560802882', + projectId: 'krow-workforce-dev', + storageBucket: 'krow-workforce-dev.firebasestorage.app', + androidClientId: + '933560802882-fbqg2icq24bmci3f84evjrbth5huh87f.apps.googleusercontent.com', + iosClientId: + '933560802882-jpv087j5jenp1h63mc9ge51767s3l2ac.apps.googleusercontent.com', + iosBundleId: 'dev.krowwithus.client', + ); + + static const FirebaseOptions _devWeb = FirebaseOptions( apiKey: 'AIzaSyBqRtZPMGU-Sz5x5UnRrunKu5NSWYyPRn8', appId: '1:933560802882:web:173a841992885bb27757db', messagingSenderId: '933560802882', @@ -56,23 +92,62 @@ class DefaultFirebaseOptions { measurementId: 'G-9S7WEQTDKX', ); - static const FirebaseOptions android = FirebaseOptions( - apiKey: 'AIzaSyDBYhflhK6DThKnS7RM-9raKdvyKzLUjY4', - appId: '1:933560802882:android:da13569105659ead7757db', - messagingSenderId: '933560802882', - projectId: 'krow-workforce-dev', - storageBucket: 'krow-workforce-dev.firebasestorage.app', + // =========================================================================== + // STAGE (krow-workforce-staging) + // =========================================================================== + + static const FirebaseOptions _stageAndroid = FirebaseOptions( + apiKey: 'AIzaSyCgTXI3QhbEK3r4J5y7ek_6AxqhmR99QjY', + appId: '1:1032971403708:android:1ab9badf171c3aca356bb9', + messagingSenderId: '1032971403708', + projectId: 'krow-workforce-staging', + storageBucket: 'krow-workforce-staging.firebasestorage.app', ); - static const FirebaseOptions ios = FirebaseOptions( - apiKey: 'AIzaSyDyEXkzZAWpXXe4dAesYaZflt5BEtMn9tA', - appId: '1:933560802882:ios:d2b6d743608e2a527757db', - messagingSenderId: '933560802882', - projectId: 'krow-workforce-dev', - storageBucket: 'krow-workforce-dev.firebasestorage.app', - androidClientId: '933560802882-fbqg2icq24bmci3f84evjrbth5huh87f.apps.googleusercontent.com', - iosClientId: '933560802882-jqpv1l3gjmi3m87b2gu1iq4lg46lkdfg.apps.googleusercontent.com', - iosBundleId: 'com.krowwithus.client', + static const FirebaseOptions _stageIos = FirebaseOptions( + apiKey: 'AIzaSyCgTXI3QhbEK3r4J5y7ek_6AxqhmR99QjY', + appId: '1:1032971403708:ios:0ff547e80f5324ed356bb9', + messagingSenderId: '1032971403708', + projectId: 'krow-workforce-staging', + storageBucket: 'krow-workforce-staging.firebasestorage.app', + iosBundleId: 'stage.krowwithus.client', ); -} \ No newline at end of file + static const FirebaseOptions _stageWeb = FirebaseOptions( + apiKey: 'AIzaSyCgTXI3QhbEK3r4J5y7ek_6AxqhmR99QjY', + appId: '', // TODO: Register web app in krow-workforce-staging + messagingSenderId: '1032971403708', + projectId: 'krow-workforce-staging', + storageBucket: 'krow-workforce-staging.firebasestorage.app', + ); + + // =========================================================================== + // PROD (krow-workforce-prod) + // TODO: Fill in after creating krow-workforce-prod Firebase project + // =========================================================================== + + static const FirebaseOptions _prodAndroid = FirebaseOptions( + apiKey: '', // TODO: Add prod API key + appId: '', // TODO: Add prod app ID + messagingSenderId: '', // TODO: Add prod sender ID + projectId: 'krow-workforce-prod', + storageBucket: 'krow-workforce-prod.firebasestorage.app', + ); + + static const FirebaseOptions _prodIos = FirebaseOptions( + apiKey: '', // TODO: Add prod API key + appId: '', // TODO: Add prod app ID + messagingSenderId: '', // TODO: Add prod sender ID + projectId: 'krow-workforce-prod', + storageBucket: 'krow-workforce-prod.firebasestorage.app', + iosBundleId: 'prod.krowwithus.client', + ); + + static const FirebaseOptions _prodWeb = FirebaseOptions( + apiKey: '', // TODO: Add prod API key + appId: '', // TODO: Add prod app ID + messagingSenderId: '', // TODO: Add prod sender ID + projectId: 'krow-workforce-prod', + storageBucket: 'krow-workforce-prod.firebasestorage.app', + ); +} diff --git a/apps/mobile/apps/client/lib/src/widgets/session_listener.dart b/apps/mobile/apps/client/lib/src/widgets/session_listener.dart index cbae1627..707d5cf7 100644 --- a/apps/mobile/apps/client/lib/src/widgets/session_listener.dart +++ b/apps/mobile/apps/client/lib/src/widgets/session_listener.dart @@ -104,7 +104,7 @@ class _SessionListenerState extends State { actions: [ TextButton( onPressed: () { - Navigator.of(context).pop(); + Modular.to.popSafe(); _proceedToLogin(); }, child: const Text('Log In'), @@ -134,7 +134,7 @@ class _SessionListenerState extends State { ), TextButton( onPressed: () { - Navigator.of(context).pop(); + Modular.to.popSafe();; _proceedToLogin(); }, child: const Text('Log Out'), diff --git a/apps/mobile/apps/client/pubspec.yaml b/apps/mobile/apps/client/pubspec.yaml index f9e3d656..677133d7 100644 --- a/apps/mobile/apps/client/pubspec.yaml +++ b/apps/mobile/apps/client/pubspec.yaml @@ -1,7 +1,7 @@ name: krowwithus_client description: "KROW Client Application" publish_to: "none" -version: 0.0.1-IlianaClientM3 +version: 0.0.1-m4 resolution: workspace environment: diff --git a/apps/mobile/apps/staff/CHANGELOG.md b/apps/mobile/apps/staff/CHANGELOG.md new file mode 100644 index 00000000..9d81362d --- /dev/null +++ b/apps/mobile/apps/staff/CHANGELOG.md @@ -0,0 +1,193 @@ +# Staff Mobile App - Change Log + +## [v0.0.1-m3] - Milestone 3 - 2026-02-15 + +### Added - Authentication & Onboarding +- Phone number authentication with OTP verification +- Staff onboarding flow with profile setup +- Personal information collection (name, bio, languages) +- Preferred work locations selection +- Skills and industry selection + +### Added - Home Dashboard +- Welcome screen with personalized greeting +- Today's shifts section showing confirmed shifts +- Tomorrow's shifts preview +- Recommended shifts section based on profile +- Quick action buttons (Find Shifts, Availability, Messages, Earnings) + +### Added - Shift Management +- Find Shifts functionality to discover available work +- Shift details view showing: + - Business name and location + - Hourly rate and estimated earnings + - Date, start time, end time + - Job requirements + - Map integration with directions +- Shift booking/application process +- Booking confirmation dialog +- My Shifts view with week-by-week navigation +- Color-coded shift status (Confirmed, Pending, Completed) + +### Added - Clock In/Out +- Clock In page with slider interaction +- Clock Out page with slider interaction +- Automatic timestamp recording +- Shift status updates upon clock in/out +- Visual status indicators (green for checked in) + +### Added - Profile Management +- Profile tab with personal information +- Emergency Contact management: + - Contact name + - Relationship + - Phone number +- Bank Account linking for direct deposit +- Tax Forms section: + - W-4 form access + - I-9 form access +- Time Card view: + - Historical shift records + - Hours worked tracking + - Earnings history + +### Added - Navigation +- Bottom navigation bar with 5 tabs: + - Shifts + - Payments + - Home + - Clock In + - Profile +- Tab bar hiding on specific pages + +### Technical Features +- Firebase authentication integration +- Data Connect backend integration +- Google Maps integration for locations +- Phone verification system +- OTP code handling + +### Known Limitations +- Newly created orders don't appear immediately in Find Shifts (requires vendor approval) +- Limited to one-time order types in this milestone + +--- + +## [v0.0.1-m4] - Milestone 4 - 2026-03-05 + +### Added - Enhanced Authentication & Session Management +- Authentication session persistence across app restarts +- Automatic login with valid session tokens +- Improved user experience with seamless session handling + +### Added - Enhanced Shift Details +- Google Maps location display in shift details view +- Interactive map showing shift location +- Directions integration +- Shift requirements section showing: + - Required attire items (MUST HAVE) + - Preferred attire items (NICE TO HAVE) + - Other shift-specific requirements + +### Added - Attire Management +- Dedicated Attire screen in profile +- Camera and gallery support for attire photo capture +- Local image preview before submission +- Upload attire images for verification +- MUST HAVE attire items list +- NICE TO HAVE attire items list +- Attire photo gallery in profile +- Submit attire for review workflow +- Attire verification status tracking (Pending, Approved, Rejected) +- Attestation checkbox for attire accuracy confirmation +- Filtered attire items based on requirements + +### Added - Documents & Certificates Management +- Documents section in profile with verification status tracking +- Upload documents (ID, licenses, etc.) with: + - Camera or gallery selection + - File type validation + - Upload progress tracking + - Verification metadata +- Certificates management: + - Upload certificates with expiry dates + - Certificate number field + - Certificate type selection + - View existing certificates + - Delete certificates + - Verification status (Not Verified, Verified, Expired) +- Mandatory document flagging +- Document verification workflow + +### Added - Profile Enhancements +- FAQ (Frequently Asked Questions) screen +- Privacy and Security settings screen: + - Profile visibility toggle ("Hide account from business") + - Terms of Service document access + - Privacy Policy document access +- Preferred locations management: + - Dedicated edit screen + - Location search functionality + - Display selected locations +- Language selection interface: + - Spanish language support + - Success feedback on language change + - Persistent language preference +- Benefits overview section: + - Benefits listing with circular progress indicators + - Benefits dashboard integration +- Profile completion tracking for: + - Personal information + - Emergency contacts + - Experience + - Attire + - Documents + - Certificates + +### Added - Profile Completion Gating +- Navigation restrictions for incomplete profiles +- Only Home and Profile tabs accessible until profile is complete +- Profile completion checklist +- Guided onboarding completion flow + +### Improved - User Experience +- Enhanced shift details UI with better information hierarchy +- Improved profile section organization +- Better navigation flow for profile completion +- UiEmptyState widgets for better empty state handling: + - Bank account page empty state + - Home page when no shifts available +- Improved onboarding flow with refactored experience and personal info pages +- Enhanced emergency contact screen with info banner +- Refactored profile header with profile level badge ("KROWER I") +- Benefits card components with improved styling +- Bottom navigation bar show/hide based on route +- Tax forms page with progress overview +- Improved notice and file type banners for uploads +- Enhanced navigation robustness with proper error handling +- Immediate ID token refresh after sign-in to prevent unauthenticated requests + +### Fixed +- Profile completion status now updates correctly for emergency contacts +- Session handling improved to prevent data loss +- Navigation errors redirect to appropriate home page +- Locale synchronization by reloading from persistent storage after change + +### Technical Features +- Enhanced backend validation for shift acceptance +- Overlapping shift prevention +- Improved session management +- Document upload and storage integration +- Signed URL generation for file uploads +- Camera and gallery native device access +- File visibility controls (public/private) +- Core API services integration (verification, file upload, LLM) +- ApiService with Dio for standardized API requests +- Device services abstraction layer + +### Known Limitations +- Cannot accept overlapping shifts +- Shifts require manual confirmation in some cases +- Attire verification requires manual client approval + +--- diff --git a/apps/mobile/apps/staff/android/app/build.gradle.kts b/apps/mobile/apps/staff/android/app/build.gradle.kts index 096fb2b4..84df41b4 100644 --- a/apps/mobile/apps/staff/android/app/build.gradle.kts +++ b/apps/mobile/apps/staff/android/app/build.gradle.kts @@ -21,8 +21,20 @@ dartDefinesString.split(",").forEach { } } +// Load flavor-specific key properties: key.dev.properties, key.stage.properties, key.prod.properties +// The active flavor is resolved from the Gradle task name (e.g. assembleDevRelease -> dev) +fun resolveFlavorFromTask(): String { + val taskNames = gradle.startParameter.taskNames.joinToString(" ").lowercase() + return when { + taskNames.contains("prod") -> "prod" + taskNames.contains("stage") -> "stage" + else -> "dev" + } +} + +val activeFlavorForSigning = resolveFlavorFromTask() val keystoreProperties = Properties().apply { - val propertiesFile = rootProject.file("key.properties") + val propertiesFile = rootProject.file("key.${activeFlavorForSigning}.properties") if (propertiesFile.exists()) { load(propertiesFile.inputStream()) } @@ -43,9 +55,7 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.krowwithus.staff" - // You can update the following values to match your application needs. + // applicationId is set per flavor below // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion @@ -55,16 +65,35 @@ android { manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartEnvironmentVariables["GOOGLE_MAPS_API_KEY"] ?: "" } + flavorDimensions += "environment" + productFlavors { + create("dev") { + dimension = "environment" + applicationId = "dev.krowwithus.staff" + resValue("string", "app_name", "KROW With Us [DEV]") + } + create("stage") { + dimension = "environment" + applicationId = "stage.krowwithus.staff" + resValue("string", "app_name", "KROW With Us [STG]") + } + create("prod") { + dimension = "environment" + applicationId = "prod.krowwithus.staff" + resValue("string", "app_name", "KROW Staff") + } + } + signingConfigs { create("release") { if (System.getenv()["CI"] == "true") { // CodeMagic CI environment - storeFile = file(System.getenv()["CM_KEYSTORE_PATH_STAFF"] ?: "") - storePassword = System.getenv()["CM_KEYSTORE_PASSWORD_STAFF"] - keyAlias = System.getenv()["CM_KEY_ALIAS_STAFF"] - keyPassword = System.getenv()["CM_KEY_PASSWORD_STAFF"] + storeFile = file(System.getenv()["CM_KEYSTORE_PATH"] ?: "") + storePassword = System.getenv()["CM_KEYSTORE_PASSWORD"] + keyAlias = System.getenv()["CM_KEY_ALIAS"] + keyPassword = System.getenv()["CM_KEY_PASSWORD"] } else { - // Local development environment + // Local development environment — loads from key..properties keyAlias = keystoreProperties["keyAlias"] as String? keyPassword = keystoreProperties["keyPassword"] as String? storeFile = keystoreProperties["storeFile"]?.let { file(it) } @@ -84,6 +113,20 @@ android { } } +// Skip google-services processing for flavors whose google-services.json +// contains placeholder values (e.g. prod before the Firebase project exists). +// Once a real config is dropped in, the task automatically re-enables. +afterEvaluate { + tasks.matching { + it.name.startsWith("process") && it.name.endsWith("GoogleServices") + }.configureEach { + val taskFlavor = name.removePrefix("process").removeSuffix("GoogleServices") + .removeSuffix("Debug").removeSuffix("Release").lowercase() + val configFile = file("src/$taskFlavor/google-services.json") + enabled = configFile.exists() && configFile.readText().contains("\"mobilesdk_app_id\": \"1:") + } +} + flutter { source = "../.." } diff --git a/apps/mobile/apps/staff/android/app/google-services.json_back b/apps/mobile/apps/staff/android/app/google-services.json_back deleted file mode 100644 index f4d57e10..00000000 --- a/apps/mobile/apps/staff/android/app/google-services.json_back +++ /dev/null @@ -1,162 +0,0 @@ -{ - "project_info": { - "project_number": "933560802882", - "project_id": "krow-workforce-dev", - "storage_bucket": "krow-workforce-dev.firebasestorage.app" - }, - "client": [ - { - "client_info": { - "mobilesdk_app_id": "1:933560802882:android:edcddb83ea4bbb517757db", - "android_client_info": { - "package_name": "com.krow.app.business.dev" - } - }, - "oauth_client": [ - { - "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", - "client_type": 3 - } - ], - "api_key": [ - { - "current_key": "AIzaSyDBYhflhK6DThKnS7RM-9raKdvyKzLUjY4" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [ - { - "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", - "client_type": 3 - }, - { - "client_id": "933560802882-dppsapp5i3lsfrlm1mhob2s21peofg1t.apps.googleusercontent.com", - "client_type": 2, - "ios_info": { - "bundle_id": "com.krow.app.staff.dev" - } - } - ] - } - } - }, - { - "client_info": { - "mobilesdk_app_id": "1:933560802882:android:d49b8c0f4d19e95e7757db", - "android_client_info": { - "package_name": "com.krow.app.staff.dev" - } - }, - "oauth_client": [ - { - "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", - "client_type": 3 - } - ], - "api_key": [ - { - "current_key": "AIzaSyDBYhflhK6DThKnS7RM-9raKdvyKzLUjY4" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [ - { - "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", - "client_type": 3 - }, - { - "client_id": "933560802882-dppsapp5i3lsfrlm1mhob2s21peofg1t.apps.googleusercontent.com", - "client_type": 2, - "ios_info": { - "bundle_id": "com.krow.app.staff.dev" - } - } - ] - } - } - }, - { - "client_info": { - "mobilesdk_app_id": "1:933560802882:android:da13569105659ead7757db", - "android_client_info": { - "package_name": "com.krowwithus.client" - } - }, - "oauth_client": [ - { - "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", - "client_type": 3 - } - ], - "api_key": [ - { - "current_key": "AIzaSyDBYhflhK6DThKnS7RM-9raKdvyKzLUjY4" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [ - { - "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", - "client_type": 3 - }, - { - "client_id": "933560802882-dppsapp5i3lsfrlm1mhob2s21peofg1t.apps.googleusercontent.com", - "client_type": 2, - "ios_info": { - "bundle_id": "com.krow.app.staff.dev" - } - } - ] - } - } - }, - { - "client_info": { - "mobilesdk_app_id": "1:933560802882:android:1ae05d85c865f77c7757db", - "android_client_info": { - "package_name": "com.krowwithus.staff" - } - }, - "oauth_client": [ - { - "client_id": "933560802882-ikdfv3o5f47g36qqgvfq55o4m19n7gk4.apps.googleusercontent.com", - "client_type": 1, - "android_info": { - "package_name": "com.krowwithus.staff", - "certificate_hash": "ac917ae8470ab29f1107c773c6017ff5ea5d102d" - } - }, - { - "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", - "client_type": 3 - } - ], - "api_key": [ - { - "current_key": "AIzaSyDBYhflhK6DThKnS7RM-9raKdvyKzLUjY4" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [ - { - "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", - "client_type": 3 - }, - { - "client_id": "933560802882-dppsapp5i3lsfrlm1mhob2s21peofg1t.apps.googleusercontent.com", - "client_type": 2, - "ios_info": { - "bundle_id": "com.krow.app.staff.dev" - } - } - ] - } - } - } - ], - "configuration_version": "1" -} \ No newline at end of file diff --git a/apps/mobile/apps/staff/android/app/google-services.json b/apps/mobile/apps/staff/android/app/src/dev/google-services.json similarity index 94% rename from apps/mobile/apps/staff/android/app/google-services.json rename to apps/mobile/apps/staff/android/app/src/dev/google-services.json index 8d5acf3a..ca0a39ea 100644 --- a/apps/mobile/apps/staff/android/app/google-services.json +++ b/apps/mobile/apps/staff/android/app/src/dev/google-services.json @@ -5,78 +5,6 @@ "storage_bucket": "krow-workforce-dev.firebasestorage.app" }, "client": [ - { - "client_info": { - "mobilesdk_app_id": "1:933560802882:android:edcddb83ea4bbb517757db", - "android_client_info": { - "package_name": "com.krow.app.business.dev" - } - }, - "oauth_client": [ - { - "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", - "client_type": 3 - } - ], - "api_key": [ - { - "current_key": "AIzaSyDBYhflhK6DThKnS7RM-9raKdvyKzLUjY4" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [ - { - "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", - "client_type": 3 - }, - { - "client_id": "933560802882-29olj9ku64jbe9h7flinha6hbi8qrluh.apps.googleusercontent.com", - "client_type": 2, - "ios_info": { - "bundle_id": "com.krowwithus.staff" - } - } - ] - } - } - }, - { - "client_info": { - "mobilesdk_app_id": "1:933560802882:android:d49b8c0f4d19e95e7757db", - "android_client_info": { - "package_name": "com.krow.app.staff.dev" - } - }, - "oauth_client": [ - { - "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", - "client_type": 3 - } - ], - "api_key": [ - { - "current_key": "AIzaSyDBYhflhK6DThKnS7RM-9raKdvyKzLUjY4" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [ - { - "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", - "client_type": 3 - }, - { - "client_id": "933560802882-29olj9ku64jbe9h7flinha6hbi8qrluh.apps.googleusercontent.com", - "client_type": 2, - "ios_info": { - "bundle_id": "com.krowwithus.staff" - } - } - ] - } - } - }, { "client_info": { "mobilesdk_app_id": "1:933560802882:android:da13569105659ead7757db", @@ -164,7 +92,79 @@ ] } } + }, + { + "client_info": { + "mobilesdk_app_id": "1:933560802882:android:1eb46251032273cb7757db", + "android_client_info": { + "package_name": "dev.krowwithus.client" + } + }, + "oauth_client": [ + { + "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyDBYhflhK6DThKnS7RM-9raKdvyKzLUjY4" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "933560802882-29olj9ku64jbe9h7flinha6hbi8qrluh.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "com.krowwithus.staff" + } + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:933560802882:android:ee100eab75b6b04c7757db", + "android_client_info": { + "package_name": "dev.krowwithus.staff" + } + }, + "oauth_client": [ + { + "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyDBYhflhK6DThKnS7RM-9raKdvyKzLUjY4" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "933560802882-29olj9ku64jbe9h7flinha6hbi8qrluh.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "com.krowwithus.staff" + } + } + ] + } + } } ], "configuration_version": "1" -} +} \ No newline at end of file diff --git a/apps/mobile/apps/staff/android/app/src/main/AndroidManifest.xml b/apps/mobile/apps/staff/android/app/src/main/AndroidManifest.xml index 0e093d51..9416b135 100644 --- a/apps/mobile/apps/staff/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/apps/staff/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/apps/staff/ios/Runner.xcodeproj/xcshareddata/xcschemes/prod.xcscheme b/apps/mobile/apps/staff/ios/Runner.xcodeproj/xcshareddata/xcschemes/prod.xcscheme new file mode 100644 index 00000000..35bf1848 --- /dev/null +++ b/apps/mobile/apps/staff/ios/Runner.xcodeproj/xcshareddata/xcschemes/prod.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/apps/staff/ios/Runner.xcodeproj/xcshareddata/xcschemes/stage.xcscheme b/apps/mobile/apps/staff/ios/Runner.xcodeproj/xcshareddata/xcschemes/stage.xcscheme new file mode 100644 index 00000000..35bf1848 --- /dev/null +++ b/apps/mobile/apps/staff/ios/Runner.xcodeproj/xcshareddata/xcschemes/stage.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/apps/staff/ios/Runner/Info.plist b/apps/mobile/apps/staff/ios/Runner/Info.plist index 257da050..bdc600e2 100644 --- a/apps/mobile/apps/staff/ios/Runner/Info.plist +++ b/apps/mobile/apps/staff/ios/Runner/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - KROW With Us Staff + $(APP_NAME) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -13,7 +13,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - KROW With Us Staff + $(APP_NAME) CFBundlePackageType APPL CFBundleShortVersionString diff --git a/apps/mobile/apps/staff/ios/config/dev/GoogleService-Info.plist b/apps/mobile/apps/staff/ios/config/dev/GoogleService-Info.plist new file mode 100644 index 00000000..75f58041 --- /dev/null +++ b/apps/mobile/apps/staff/ios/config/dev/GoogleService-Info.plist @@ -0,0 +1,36 @@ + + + + + CLIENT_ID + 933560802882-jpv087j5jenp1h63mc9ge51767s3l2ac.apps.googleusercontent.com + REVERSED_CLIENT_ID + com.googleusercontent.apps.933560802882-jpv087j5jenp1h63mc9ge51767s3l2ac + ANDROID_CLIENT_ID + 933560802882-fbqg2icq24bmci3f84evjrbth5huh87f.apps.googleusercontent.com + API_KEY + AIzaSyDyEXkzZAWpXXe4dAesYaZflt5BEtMn9tA + GCM_SENDER_ID + 933560802882 + PLIST_VERSION + 1 + BUNDLE_ID + dev.krowwithus.client + PROJECT_ID + krow-workforce-dev + STORAGE_BUCKET + krow-workforce-dev.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:933560802882:ios:7e179dfdd1a8994c7757db + + \ No newline at end of file diff --git a/apps/mobile/apps/staff/ios/config/stage/GoogleService-Info.plist b/apps/mobile/apps/staff/ios/config/stage/GoogleService-Info.plist new file mode 100644 index 00000000..631c0d6c --- /dev/null +++ b/apps/mobile/apps/staff/ios/config/stage/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyCgTXI3QhbEK3r4J5y7ek_6AxqhmR99QjY + GCM_SENDER_ID + 1032971403708 + PLIST_VERSION + 1 + BUNDLE_ID + stage.krowwithus.client + PROJECT_ID + krow-workforce-staging + STORAGE_BUCKET + krow-workforce-staging.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:1032971403708:ios:0ff547e80f5324ed356bb9 + + \ No newline at end of file diff --git a/apps/mobile/apps/staff/ios/scripts/firebase-config.sh b/apps/mobile/apps/staff/ios/scripts/firebase-config.sh new file mode 100755 index 00000000..b700a0ad --- /dev/null +++ b/apps/mobile/apps/staff/ios/scripts/firebase-config.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Copy the correct GoogleService-Info.plist based on the build configuration. +# This script should be added as a "Run Script" build phase in Xcode, +# BEFORE the "Compile Sources" phase. +# +# The FLUTTER_FLAVOR environment variable is set by Flutter when building +# with --flavor. It maps to: dev, stage, prod. + +FLAVOR="${FLUTTER_FLAVOR:-dev}" +PLIST_SOURCE="${PROJECT_DIR}/config/${FLAVOR}/GoogleService-Info.plist" +PLIST_DEST="${PROJECT_DIR}/Runner/GoogleService-Info.plist" + +if [ ! -f "$PLIST_SOURCE" ]; then + echo "error: GoogleService-Info.plist not found for flavor '${FLAVOR}' at ${PLIST_SOURCE}" + exit 1 +fi + +echo "Copying GoogleService-Info.plist for flavor: ${FLAVOR}" +cp "${PLIST_SOURCE}" "${PLIST_DEST}" diff --git a/apps/mobile/apps/staff/lib/firebase_options.dart b/apps/mobile/apps/staff/lib/firebase_options.dart index 3945a3a2..c47d4164 100644 --- a/apps/mobile/apps/staff/lib/firebase_options.dart +++ b/apps/mobile/apps/staff/lib/firebase_options.dart @@ -1,44 +1,22 @@ -// File generated by FlutterFire CLI. - import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb, TargetPlatform; +import 'package:krow_core/core.dart'; -/// Default [FirebaseOptions] for use with your Firebase apps. +/// Environment-aware [FirebaseOptions] for the Staff app. /// -/// Example: -/// ```dart -/// import 'firebase_options.dart'; -/// // ... -/// await Firebase.initializeApp( -/// options: DefaultFirebaseOptions.currentPlatform, -/// ); -/// ``` +/// Selects the correct Firebase configuration based on the compile-time +/// `ENV` dart define (dev, stage, prod). Defaults to dev. class DefaultFirebaseOptions { static FirebaseOptions get currentPlatform { if (kIsWeb) { - return web; + return _webOptions; } switch (defaultTargetPlatform) { case TargetPlatform.android: - return android; + return _androidOptions; case TargetPlatform.iOS: - return ios; - case TargetPlatform.macOS: - throw UnsupportedError( - 'DefaultFirebaseOptions have not been configured for macos - ' - 'you can reconfigure this by running the FlutterFire CLI again.', - ); - case TargetPlatform.windows: - throw UnsupportedError( - 'DefaultFirebaseOptions have not been configured for windows - ' - 'you can reconfigure this by running the FlutterFire CLI again.', - ); - case TargetPlatform.linux: - throw UnsupportedError( - 'DefaultFirebaseOptions have not been configured for linux - ' - 'you can reconfigure this by running the FlutterFire CLI again.', - ); + return _iosOptions; default: throw UnsupportedError( 'DefaultFirebaseOptions are not supported for this platform.', @@ -46,7 +24,65 @@ class DefaultFirebaseOptions { } } - static const FirebaseOptions web = FirebaseOptions( + static FirebaseOptions get _androidOptions { + switch (AppEnvironment.current) { + case AppEnvironment.dev: + return _devAndroid; + case AppEnvironment.stage: + return _stageAndroid; + case AppEnvironment.prod: + return _prodAndroid; + } + } + + static FirebaseOptions get _iosOptions { + switch (AppEnvironment.current) { + case AppEnvironment.dev: + return _devIos; + case AppEnvironment.stage: + return _stageIos; + case AppEnvironment.prod: + return _prodIos; + } + } + + static FirebaseOptions get _webOptions { + switch (AppEnvironment.current) { + case AppEnvironment.dev: + return _devWeb; + case AppEnvironment.stage: + return _stageWeb; + case AppEnvironment.prod: + return _prodWeb; + } + } + + // =========================================================================== + // DEV (krow-workforce-dev) + // =========================================================================== + + static const FirebaseOptions _devAndroid = FirebaseOptions( + apiKey: 'AIzaSyDBYhflhK6DThKnS7RM-9raKdvyKzLUjY4', + appId: '1:933560802882:android:ee100eab75b6b04c7757db', + messagingSenderId: '933560802882', + projectId: 'krow-workforce-dev', + storageBucket: 'krow-workforce-dev.firebasestorage.app', + ); + + static const FirebaseOptions _devIos = FirebaseOptions( + apiKey: 'AIzaSyDyEXkzZAWpXXe4dAesYaZflt5BEtMn9tA', + appId: '1:933560802882:ios:edf97dab6eb87b977757db', + messagingSenderId: '933560802882', + projectId: 'krow-workforce-dev', + storageBucket: 'krow-workforce-dev.firebasestorage.app', + androidClientId: + '933560802882-fbqg2icq24bmci3f84evjrbth5huh87f.apps.googleusercontent.com', + iosClientId: + '933560802882-fphpkdjubve8k7e8ogqj3fk1qducv3sg.apps.googleusercontent.com', + iosBundleId: 'dev.krowwithus.staff', + ); + + static const FirebaseOptions _devWeb = FirebaseOptions( apiKey: 'AIzaSyBqRtZPMGU-Sz5x5UnRrunKu5NSWYyPRn8', appId: '1:933560802882:web:173a841992885bb27757db', messagingSenderId: '933560802882', @@ -56,23 +92,62 @@ class DefaultFirebaseOptions { measurementId: 'G-9S7WEQTDKX', ); - static const FirebaseOptions android = FirebaseOptions( - apiKey: 'AIzaSyDBYhflhK6DThKnS7RM-9raKdvyKzLUjY4', - appId: '1:933560802882:android:1ae05d85c865f77c7757db', - messagingSenderId: '933560802882', - projectId: 'krow-workforce-dev', - storageBucket: 'krow-workforce-dev.firebasestorage.app', + // =========================================================================== + // STAGE (krow-workforce-staging) + // =========================================================================== + + static const FirebaseOptions _stageAndroid = FirebaseOptions( + apiKey: 'AIzaSyCgTXI3QhbEK3r4J5y7ek_6AxqhmR99QjY', + appId: '1:1032971403708:android:14e471d055e59597356bb9', + messagingSenderId: '1032971403708', + projectId: 'krow-workforce-staging', + storageBucket: 'krow-workforce-staging.firebasestorage.app', ); - static const FirebaseOptions ios = FirebaseOptions( - apiKey: 'AIzaSyDyEXkzZAWpXXe4dAesYaZflt5BEtMn9tA', - appId: '1:933560802882:ios:fa584205b356de937757db', - messagingSenderId: '933560802882', - projectId: 'krow-workforce-dev', - storageBucket: 'krow-workforce-dev.firebasestorage.app', - androidClientId: '933560802882-fbqg2icq24bmci3f84evjrbth5huh87f.apps.googleusercontent.com', - iosClientId: '933560802882-29olj9ku64jbe9h7flinha6hbi8qrluh.apps.googleusercontent.com', - iosBundleId: 'com.krowwithus.staff', + static const FirebaseOptions _stageIos = FirebaseOptions( + apiKey: 'AIzaSyCgTXI3QhbEK3r4J5y7ek_6AxqhmR99QjY', + appId: '1:1032971403708:ios:8c2bbd76bc4f55d9356bb9', + messagingSenderId: '1032971403708', + projectId: 'krow-workforce-staging', + storageBucket: 'krow-workforce-staging.firebasestorage.app', + iosBundleId: 'stage.krowwithus.staff', ); -} \ No newline at end of file + static const FirebaseOptions _stageWeb = FirebaseOptions( + apiKey: 'AIzaSyCgTXI3QhbEK3r4J5y7ek_6AxqhmR99QjY', + appId: '', // TODO: Register web app in krow-workforce-staging + messagingSenderId: '1032971403708', + projectId: 'krow-workforce-staging', + storageBucket: 'krow-workforce-staging.firebasestorage.app', + ); + + // =========================================================================== + // PROD (krow-workforce-prod) + // TODO: Fill in after creating krow-workforce-prod Firebase project + // =========================================================================== + + static const FirebaseOptions _prodAndroid = FirebaseOptions( + apiKey: '', // TODO: Add prod API key + appId: '', // TODO: Add prod app ID + messagingSenderId: '', // TODO: Add prod sender ID + projectId: 'krow-workforce-prod', + storageBucket: 'krow-workforce-prod.firebasestorage.app', + ); + + static const FirebaseOptions _prodIos = FirebaseOptions( + apiKey: '', // TODO: Add prod API key + appId: '', // TODO: Add prod app ID + messagingSenderId: '', // TODO: Add prod sender ID + projectId: 'krow-workforce-prod', + storageBucket: 'krow-workforce-prod.firebasestorage.app', + iosBundleId: 'prod.krowwithus.staff', + ); + + static const FirebaseOptions _prodWeb = FirebaseOptions( + apiKey: '', // TODO: Add prod API key + appId: '', // TODO: Add prod app ID + messagingSenderId: '', // TODO: Add prod sender ID + projectId: 'krow-workforce-prod', + storageBucket: 'krow-workforce-prod.firebasestorage.app', + ); +} diff --git a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart index fa830a35..47d9fdd0 100644 --- a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart +++ b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart @@ -104,7 +104,7 @@ class _SessionListenerState extends State { actions: [ TextButton( onPressed: () { - Navigator.of(context).pop(); + Modular.to.popSafe();; _proceedToLogin(); }, child: const Text('Log In'), @@ -134,7 +134,7 @@ class _SessionListenerState extends State { ), TextButton( onPressed: () { - Navigator.of(context).pop(); + Modular.to.popSafe();; _proceedToLogin(); }, child: const Text('Log Out'), diff --git a/apps/mobile/apps/staff/pubspec.yaml b/apps/mobile/apps/staff/pubspec.yaml index 457446fd..21c19091 100644 --- a/apps/mobile/apps/staff/pubspec.yaml +++ b/apps/mobile/apps/staff/pubspec.yaml @@ -1,7 +1,7 @@ name: krowwithus_staff description: "KROW Staff Application" publish_to: 'none' -version: 0.0.1-IlianaStaffM3 +version: 0.0.1-m4 resolution: workspace environment: diff --git a/apps/mobile/config.dev.json b/apps/mobile/config.dev.json index a6d85eec..9afaadb4 100644 --- a/apps/mobile/config.dev.json +++ b/apps/mobile/config.dev.json @@ -1,4 +1,5 @@ { + "ENV": "dev", "GOOGLE_MAPS_API_KEY": "AIzaSyAyRS9I4xxoVPAX91RJvWJHszB3ZY3-IC0", "CORE_API_BASE_URL": "https://krow-core-api-e3g6witsvq-uc.a.run.app" -} \ No newline at end of file +} diff --git a/apps/mobile/config.prod.json b/apps/mobile/config.prod.json new file mode 100644 index 00000000..4356dd24 --- /dev/null +++ b/apps/mobile/config.prod.json @@ -0,0 +1,5 @@ +{ + "ENV": "prod", + "GOOGLE_MAPS_API_KEY": "", + "CORE_API_BASE_URL": "" +} diff --git a/apps/mobile/config.stage.json b/apps/mobile/config.stage.json new file mode 100644 index 00000000..df7655bd --- /dev/null +++ b/apps/mobile/config.stage.json @@ -0,0 +1,5 @@ +{ + "ENV": "stage", + "GOOGLE_MAPS_API_KEY": "AIzaSyAyRS9I4xxoVPAX91RJvWJHszB3ZY3-IC0", + "CORE_API_BASE_URL": "https://krow-core-api-staging-e3g6witsvq-uc.a.run.app" +} diff --git a/apps/mobile/melos.yaml b/apps/mobile/melos.yaml index ae2cce43..4320c631 100644 --- a/apps/mobile/melos.yaml +++ b/apps/mobile/melos.yaml @@ -14,15 +14,14 @@ scripts: echo " 🚀 KROW WORKFORCE CUSTOM COMMANDS 🚀" echo "============================================================" echo " BUILD COMMANDS:" - echo " - melos run build:client : Build Client App (APK)" - echo " - melos run build:staff : Build Staff App (APK)" + echo " - melos run build:client -- -- --flavor --dart-define-from-file=../../config..json" + echo " - melos run build:staff -- -- --flavor --dart-define-from-file=../../config..json" echo " - melos run build:design-system : Build Design System Viewer" echo "" echo " DEBUG/START COMMANDS:" - echo " - melos run start:client -- -d : Run Client App" - echo " - melos run start:staff -- -d : Run Staff App" + echo " - melos run start:client -- -d --flavor --dart-define-from-file=../../config..json" + echo " - melos run start:staff -- -d --flavor --dart-define-from-file=../../config..json" echo " - melos run start:design-system : Run DS Viewer" - echo " (e.g., melos run start:client -- -d chrome)" echo "" echo " CODE GENERATION:" echo " - melos run gen:l10n : Generate Slang l10n" @@ -49,32 +48,30 @@ scripts: packageFilters: dependsOn: build_runner + # Single-line scripts so that melos run arg forwarding works via -- + # Usage: melos run build:client -- apk --release --flavor dev --dart-define-from-file=../../config.dev.json build:client: - run: | - melos run gen:l10n --filter="core_localization" - melos run gen:build --filter="core_localization" - melos exec --scope="krowwithus_client" -- "flutter build apk" - description: "Build the Client app (Android APK by default)." + run: melos exec --scope="krowwithus_client" -- flutter build + description: "Build the Client app. Pass args via --: -- --flavor --dart-define-from-file=../../config..json" build:staff: - run: | - melos run gen:l10n --filter="core_localization" - melos run gen:build --filter="core_localization" - melos exec --scope="krowwithus_staff" -- "flutter build apk" - description: "Build the Staff app (Android APK by default)." + run: melos exec --scope="krowwithus_staff" -- flutter build + description: "Build the Staff app. Pass args via --: -- --flavor --dart-define-from-file=../../config..json" build:design-system-viewer: - run: melos exec --scope="design_system_viewer" -- "flutter build apk" + run: melos exec --scope="design_system_viewer" -- flutter build apk description: "Build the Design System Viewer app (Android APK by default)." + # Single-line scripts so that melos run arg forwarding works via -- + # Usage: melos run start:client -- -d android --flavor dev --dart-define-from-file=../../config.dev.json start:client: - run: melos exec --scope="krowwithus_client" -- "flutter run" - description: "Start the Client app. Pass platform using -- -d , e.g. -d chrome" + run: melos exec --scope="krowwithus_client" -- flutter run + description: "Start the Client app. Pass args via --: -d --flavor --dart-define-from-file=../../config..json" start:staff: - run: melos exec --scope="krowwithus_staff" -- "flutter run" - description: "Start the Staff app. Pass platform using -- -d , e.g. -d chrome" + run: melos exec --scope="krowwithus_staff" -- flutter run + description: "Start the Staff app. Pass args via --: -d --flavor --dart-define-from-file=../../config..json" start:design-system-viewer: - run: melos exec --scope="design_system_viewer" -- "flutter run" + run: melos exec --scope="design_system_viewer" -- flutter run description: "Start the Design System Viewer app. Pass platform using -- -d , e.g. -d chrome" diff --git a/apps/mobile/packages/core/lib/core.dart b/apps/mobile/packages/core/lib/core.dart index d76a363f..e8743adc 100644 --- a/apps/mobile/packages/core/lib/core.dart +++ b/apps/mobile/packages/core/lib/core.dart @@ -9,6 +9,7 @@ export 'src/presentation/widgets/web_mobile_frame.dart'; export 'src/presentation/mixins/bloc_error_handler.dart'; export 'src/presentation/observers/core_bloc_observer.dart'; export 'src/config/app_config.dart'; +export 'src/config/app_environment.dart'; export 'src/routing/routing.dart'; export 'src/services/api_service/api_service.dart'; export 'src/services/api_service/dio_client.dart'; diff --git a/apps/mobile/packages/core/lib/src/config/app_environment.dart b/apps/mobile/packages/core/lib/src/config/app_environment.dart new file mode 100644 index 00000000..d0bd8405 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/config/app_environment.dart @@ -0,0 +1,46 @@ +/// Represents the application environment. +enum AppEnvironment { + dev, + stage, + prod; + + /// Resolves the current environment from the compile-time `ENV` dart define. + /// Defaults to [AppEnvironment.dev] if not set or unrecognized. + static AppEnvironment get current { + const String envString = String.fromEnvironment('ENV', defaultValue: 'dev'); + return AppEnvironment.values.firstWhere( + (AppEnvironment e) => e.name == envString, + orElse: () => AppEnvironment.dev, + ); + } + + /// Whether the app is running in production. + bool get isProduction => this == AppEnvironment.prod; + + /// Whether the app is running in a non-production environment. + bool get isNonProduction => !isProduction; + + /// The Firebase project ID for this environment. + String get firebaseProjectId { + switch (this) { + case AppEnvironment.dev: + return 'krow-workforce-dev'; + case AppEnvironment.stage: + return 'krow-workforce-staging'; + case AppEnvironment.prod: + return 'krow-workforce-prod'; + } + } + + /// A display label for the environment (empty for prod). + String get label { + switch (this) { + case AppEnvironment.dev: + return '[DEV]'; + case AppEnvironment.stage: + return '[STG]'; + case AppEnvironment.prod: + return ''; + } + } +} diff --git a/apps/mobile/packages/core/lib/src/core_module.dart b/apps/mobile/packages/core/lib/src/core_module.dart index 3f1c9f0c..5c71f6aa 100644 --- a/apps/mobile/packages/core/lib/src/core_module.dart +++ b/apps/mobile/packages/core/lib/src/core_module.dart @@ -13,35 +13,35 @@ class CoreModule extends Module { @override void exportedBinds(Injector i) { // 1. Register the base HTTP client - i.addSingleton(() => DioClient()); + i.addLazySingleton(() => DioClient()); // 2. Register the base API service - i.addSingleton(() => ApiService(i.get())); + i.addLazySingleton(() => ApiService(i.get())); // 3. Register Core API Services (Orchestrators) - i.addSingleton( + i.addLazySingleton( () => FileUploadService(i.get()), ); - i.addSingleton( + i.addLazySingleton( () => SignedUrlService(i.get()), ); - i.addSingleton( + i.addLazySingleton( () => VerificationService(i.get()), ); - i.addSingleton(() => LlmService(i.get())); - i.addSingleton( + i.addLazySingleton(() => LlmService(i.get())); + i.addLazySingleton( () => RapidOrderService(i.get()), ); // 4. Register Device dependency - i.addSingleton(() => ImagePicker()); + i.addLazySingleton(() => ImagePicker()); // 5. Register Device Services - i.addSingleton(() => CameraService(i.get())); - i.addSingleton(() => GalleryService(i.get())); - i.addSingleton(FilePickerService.new); - i.addSingleton(AudioRecorderService.new); - i.addSingleton( + i.addLazySingleton(() => CameraService(i.get())); + i.addLazySingleton(() => GalleryService(i.get())); + i.addLazySingleton(FilePickerService.new); + i.addLazySingleton(AudioRecorderService.new); + i.addLazySingleton( () => DeviceFileUploadService( cameraService: i.get(), galleryService: i.get(), diff --git a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart index 54746a8d..e767ade7 100644 --- a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart @@ -210,6 +210,13 @@ extension ClientNavigator on IModularNavigator { safePush(ClientPaths.createOrderPermanent, arguments: arguments); } + /// Pushes the review order page before submission. + /// + /// Returns `true` if the user confirmed submission, `null` if they went back. + Future toCreateOrderReview({Object? arguments}) async { + return safePush(ClientPaths.createOrderReview, arguments: arguments); + } + // ========================================================================== // VIEW ORDER // ========================================================================== diff --git a/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart b/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart index 7575229d..a7e7e174 100644 --- a/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart +++ b/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart @@ -154,4 +154,9 @@ class ClientPaths { /// /// Create a long-term or permanent staffing position. static const String createOrderPermanent = '/create-order/permanent'; + + /// Review order before submission. + /// + /// Summary page shown before posting any order type. + static const String createOrderReview = '/create-order/review'; } diff --git a/apps/mobile/packages/core_localization/lib/src/data/repositories_impl/locale_repository_impl.dart b/apps/mobile/packages/core_localization/lib/src/data/repositories_impl/locale_repository_impl.dart index 861f579f..be8f1e24 100644 --- a/apps/mobile/packages/core_localization/lib/src/data/repositories_impl/locale_repository_impl.dart +++ b/apps/mobile/packages/core_localization/lib/src/data/repositories_impl/locale_repository_impl.dart @@ -1,4 +1,5 @@ import 'dart:ui'; + import 'package:core_localization/src/l10n/strings.g.dart'; import '../../domain/repositories/locale_repository_interface.dart'; @@ -31,12 +32,10 @@ class LocaleRepositoryImpl implements LocaleRepositoryInterface { return getDefaultLocale(); } + /// We can hardcode this to english based on customer requirements, + /// but in a more dynamic app this should be the device locale or a fallback to english. @override Locale getDefaultLocale() { - final Locale deviceLocale = AppLocaleUtils.findDeviceLocale().flutterLocale; - if (getSupportedLocales().contains(deviceLocale)) { - return deviceLocale; - } return const Locale('en'); } 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 52fbdc50..7178240d 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 @@ -325,6 +325,8 @@ "client_create_order": { "title": "Create Order", "section_title": "ORDER TYPE", + "no_vendors_title": "No Vendors Available", + "no_vendors_description": "There are no staffing vendors associated with your account.", "types": { "rapid": "RAPID", "rapid_desc": "URGENT same-day Coverage", @@ -397,6 +399,33 @@ "title": "Permanent Order", "subtitle": "Long-term staffing placement", "placeholder": "Permanent Order Flow (Work in Progress)" + }, + "review": { + "invalid_arguments": "Unable to load order review. Please go back and try again.", + "title": "Review & Submit", + "subtitle": "Confirm details before posting", + "edit": "Edit", + "basics": "Basics", + "order_name": "Order Name", + "hub": "Hub", + "shift_contact": "Shift Contact", + "schedule": "Schedule", + "date": "Date", + "time": "Time", + "duration": "Duration", + "start_date": "Start Date", + "end_date": "End Date", + "repeat": "Repeat", + "positions": "POSITIONS", + "total": "Total", + "estimated_total": "Estimated Total", + "estimated_weekly_total": "Estimated Weekly Total", + "post_order": "Post Order", + "hours_suffix": "hrs" + }, + "rapid_draft": { + "title": "Rapid Order", + "subtitle": "Verify the order details" } }, "client_main": { @@ -1249,7 +1278,7 @@ "clock_in": "CLOCK IN", "decline": "DECLINE", "accept_shift": "ACCEPT SHIFT", - "apply_now": "APPLY NOW", + "apply_now": "BOOK SHIFT", "book_dialog": { "title": "Book Shift", "message": "Do you want to instantly book this shift?" @@ -1665,9 +1694,44 @@ "todays_cost": "Today's Cost", "no_shifts_day": "No shifts scheduled for this day", "no_workers_assigned": "No workers assigned yet", + "status_checked_in_at": "Checked In at $time", + "status_on_site": "On Site", + "status_en_route": "En Route", + "status_en_route_expected": "En Route - Expected $time", + "status_confirmed": "Confirmed", + "status_running_late": "Running Late", + "status_late": "Late", + "status_checked_out": "Checked Out", + "status_done": "Done", + "status_no_show": "No Show", + "status_completed": "Completed", "worker_row": { "verify": "Verify", "verified_message": "Worker attire verified for $name" + }, + "page": { + "daily_coverage": "Daily Coverage", + "coverage_status": "Coverage Status", + "workers": "Workers", + "error_occurred": "An error occurred", + "retry": "Retry", + "shifts": "Shifts" + }, + "calendar": { + "prev_week": "\u2190 Prev Week", + "today": "Today", + "next_week": "Next Week \u2192" + }, + "stats": { + "checked_in": "Checked In", + "en_route": "En Route" + }, + "alert": { + "workers_running_late(count)": { + "one": "$count worker is running late", + "other": "$count workers are running late" + }, + "auto_backup_searching": "Auto-backup system is searching for replacements." } }, "client_reports_common": { 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 3e057580..5fce4a09 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 @@ -325,6 +325,8 @@ "client_create_order": { "title": "Crear Orden", "section_title": "TIPO DE ORDEN", + "no_vendors_title": "No Hay Proveedores Disponibles", + "no_vendors_description": "No hay proveedores de personal asociados con su cuenta.", "types": { "rapid": "R\u00c1PIDO", "rapid_desc": "Cobertura URGENTE mismo d\u00eda", @@ -397,6 +399,33 @@ "title": "Orden Permanente", "subtitle": "Colocaci\u00f3n de personal a largo plazo", "placeholder": "Flujo de Orden Permanente (Trabajo en Progreso)" + }, + "review": { + "invalid_arguments": "No se pudo cargar la revisi\u00f3n de la orden. Por favor, regresa e intenta de nuevo.", + "title": "Revisar y Enviar", + "subtitle": "Confirma los detalles antes de publicar", + "edit": "Editar", + "basics": "Datos B\u00e1sicos", + "order_name": "Nombre de la Orden", + "hub": "Hub", + "shift_contact": "Contacto del Turno", + "schedule": "Horario", + "date": "Fecha", + "time": "Hora", + "duration": "Duraci\u00f3n", + "start_date": "Fecha de Inicio", + "end_date": "Fecha de Fin", + "repeat": "Repetir", + "positions": "POSICIONES", + "total": "Total", + "estimated_total": "Total Estimado", + "estimated_weekly_total": "Total Semanal Estimado", + "post_order": "Publicar Orden", + "hours_suffix": "hrs" + }, + "rapid_draft": { + "title": "Orden R\u00e1pida", + "subtitle": "Verifica los detalles de la orden" } }, "client_main": { @@ -1244,7 +1273,7 @@ "clock_in": "ENTRADA", "decline": "RECHAZAR", "accept_shift": "ACEPTAR TURNO", - "apply_now": "SOLICITAR AHORA", + "apply_now": "RESERVAR TURNO", "book_dialog": { "title": "Reservar turno", "message": "\u00bfDesea reservar este turno al instante?" @@ -1665,9 +1694,44 @@ "todays_cost": "Costo de Hoy", "no_shifts_day": "No hay turnos programados para este día", "no_workers_assigned": "Aún no hay trabajadores asignados", + "status_checked_in_at": "Registrado a las $time", + "status_on_site": "En Sitio", + "status_en_route": "En Camino", + "status_en_route_expected": "En Camino - Esperado $time", + "status_confirmed": "Confirmado", + "status_running_late": "Llegando Tarde", + "status_late": "Tarde", + "status_checked_out": "Salida Registrada", + "status_done": "Hecho", + "status_no_show": "No Se Presentó", + "status_completed": "Completado", "worker_row": { "verify": "Verificar", "verified_message": "Vestimenta del trabajador verificada para $name" + }, + "page": { + "daily_coverage": "Cobertura Diaria", + "coverage_status": "Estado de Cobertura", + "workers": "Trabajadores", + "error_occurred": "Ocurri\u00f3 un error", + "retry": "Reintentar", + "shifts": "Turnos" + }, + "calendar": { + "prev_week": "\u2190 Semana Anterior", + "today": "Hoy", + "next_week": "Semana Siguiente \u2192" + }, + "stats": { + "checked_in": "Registrado", + "en_route": "En Camino" + }, + "alert": { + "workers_running_late(count)": { + "one": "$count trabajador est\u00e1 llegando tarde", + "other": "$count trabajadores est\u00e1n llegando tarde" + }, + "auto_backup_searching": "El sistema de respaldo autom\u00e1tico est\u00e1 buscando reemplazos." } }, "client_reports_common": { diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart index c046918c..c48ac0a4 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart @@ -1,10 +1,12 @@ // ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs import 'dart:convert'; -import 'package:firebase_data_connect/src/core/ref.dart'; + +import 'package:firebase_data_connect/firebase_data_connect.dart'; import 'package:http/http.dart' as http; import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_domain/krow_domain.dart'; + import '../../domain/repositories/hubs_connector_repository.dart'; /// Implementation of [HubsConnectorRepository]. diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart index e5f0f4d5..770f1d68 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart @@ -20,7 +20,6 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { @override Future getProfileCompletion() async { - return true; return _service.run(() async { final String staffId = await _service.getStaffId(); diff --git a/apps/mobile/packages/design_system/lib/design_system.dart b/apps/mobile/packages/design_system/lib/design_system.dart index 36c51fad..5ffe5f13 100644 --- a/apps/mobile/packages/design_system/lib/design_system.dart +++ b/apps/mobile/packages/design_system/lib/design_system.dart @@ -14,3 +14,6 @@ export 'src/widgets/ui_loading_page.dart'; export 'src/widgets/ui_snackbar.dart'; export 'src/widgets/ui_notice_banner.dart'; export 'src/widgets/ui_empty_state.dart'; +export 'src/widgets/shimmer/ui_shimmer.dart'; +export 'src/widgets/shimmer/ui_shimmer_shapes.dart'; +export 'src/widgets/shimmer/ui_shimmer_presets.dart'; diff --git a/apps/mobile/packages/design_system/lib/src/ui_typography.dart b/apps/mobile/packages/design_system/lib/src/ui_typography.dart index 42567ce4..2293ecd8 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_typography.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_typography.dart @@ -322,7 +322,7 @@ class UiTypography { /// Body 1 Medium - Font: Instrument Sans, Size: 16, Height: 1.5 (#121826) static final TextStyle body1m = _primaryBase.copyWith( - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, fontSize: 16, height: 1.5, letterSpacing: -0.025, diff --git a/apps/mobile/packages/design_system/lib/src/widgets/shimmer/ui_shimmer.dart b/apps/mobile/packages/design_system/lib/src/widgets/shimmer/ui_shimmer.dart new file mode 100644 index 00000000..7fc83708 --- /dev/null +++ b/apps/mobile/packages/design_system/lib/src/widgets/shimmer/ui_shimmer.dart @@ -0,0 +1,27 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; + +/// Core shimmer wrapper that applies an animated gradient effect to its child. +/// +/// Wraps the `shimmer` package's [Shimmer.fromColors] using design system +/// color tokens. Place shimmer shape primitives as children. +class UiShimmer extends StatelessWidget { + /// Creates a shimmer effect wrapper around [child]. + const UiShimmer({ + super.key, + required this.child, + }); + + /// The widget tree to apply the shimmer gradient over. + final Widget child; + + @override + Widget build(BuildContext context) { + return Shimmer.fromColors( + baseColor: UiColors.muted, + highlightColor: UiColors.background, + child: child, + ); + } +} diff --git a/apps/mobile/packages/design_system/lib/src/widgets/shimmer/ui_shimmer_presets.dart b/apps/mobile/packages/design_system/lib/src/widgets/shimmer/ui_shimmer_presets.dart new file mode 100644 index 00000000..c8478cfc --- /dev/null +++ b/apps/mobile/packages/design_system/lib/src/widgets/shimmer/ui_shimmer_presets.dart @@ -0,0 +1,122 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// List-row shimmer skeleton with a leading circle, two text lines, and a +/// trailing box. +/// +/// Mimics a typical list item layout during loading. Wrap with [UiShimmer] +/// to activate the animated gradient. +class UiShimmerListItem extends StatelessWidget { + /// Creates a list-row shimmer skeleton. + const UiShimmerListItem({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric( + vertical: UiConstants.space2, + ), + child: Row( + spacing: UiConstants.space3, + children: [ + UiShimmerCircle(size: UiConstants.space10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: UiConstants.space2, + children: [ + UiShimmerLine(width: 160), + UiShimmerLine(width: 100, height: 12), + ], + ), + ), + UiShimmerBox(width: 48, height: 24), + ], + ), + ); + } +} + +/// Stats-card shimmer skeleton with an icon placeholder, a short label line, +/// and a taller value line. +/// +/// Wrapped in a bordered container matching the design system card pattern. +/// Wrap with [UiShimmer] to activate the animated gradient. +class UiShimmerStatsCard extends StatelessWidget { + /// Creates a stats-card shimmer skeleton. + const UiShimmerStatsCard({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + color: UiColors.cardViewBackground, + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerCircle(size: UiConstants.space8), + SizedBox(height: UiConstants.space3), + UiShimmerLine(width: 80, height: 12), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 120, height: 20), + ], + ), + ); + } +} + +/// Section-header shimmer skeleton rendering a single wide line placeholder. +/// +/// Wrap with [UiShimmer] to activate the animated gradient. +class UiShimmerSectionHeader extends StatelessWidget { + /// Creates a section-header shimmer skeleton. + const UiShimmerSectionHeader({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: UiConstants.space2), + child: UiShimmerLine(width: 200, height: 18), + ); + } +} + +/// Repeats a shimmer widget [itemCount] times in a [Column] with spacing. +/// +/// Use [itemBuilder] to produce each item. Wrap the entire list with +/// [UiShimmer] to share a single animated gradient across all items. +class UiShimmerList extends StatelessWidget { + /// Creates a shimmer list with [itemCount] items built by [itemBuilder]. + const UiShimmerList({ + super.key, + required this.itemBuilder, + this.itemCount = 3, + this.spacing, + }); + + /// Builder that produces each shimmer placeholder item by index. + final Widget Function(int index) itemBuilder; + + /// Number of shimmer items to render. Defaults to 3. + final int itemCount; + + /// Vertical spacing between items. Defaults to [UiConstants.space3]. + final double? spacing; + + @override + Widget build(BuildContext context) { + final double gap = spacing ?? UiConstants.space3; + return Column( + children: List.generate(itemCount, (int index) { + return Padding( + padding: EdgeInsets.only(bottom: index < itemCount - 1 ? gap : 0), + child: itemBuilder(index), + ); + }), + ); + } +} diff --git a/apps/mobile/packages/design_system/lib/src/widgets/shimmer/ui_shimmer_shapes.dart b/apps/mobile/packages/design_system/lib/src/widgets/shimmer/ui_shimmer_shapes.dart new file mode 100644 index 00000000..4fcc1ba2 --- /dev/null +++ b/apps/mobile/packages/design_system/lib/src/widgets/shimmer/ui_shimmer_shapes.dart @@ -0,0 +1,95 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Rectangular shimmer placeholder with configurable dimensions and corner radius. +/// +/// Renders as a solid white container; the parent [UiShimmer] applies the +/// animated gradient. +class UiShimmerBox extends StatelessWidget { + /// Creates a rectangular shimmer placeholder. + const UiShimmerBox({ + super.key, + required this.width, + required this.height, + this.borderRadius, + }); + + /// Width of the placeholder rectangle. + final double width; + + /// Height of the placeholder rectangle. + final double height; + + /// Corner radius. Defaults to [UiConstants.radiusMd] when null. + final BorderRadius? borderRadius; + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: borderRadius ?? UiConstants.radiusMd, + ), + ); + } +} + +/// Circular shimmer placeholder with a configurable diameter. +/// +/// Renders as a solid white circle; the parent [UiShimmer] applies the +/// animated gradient. +class UiShimmerCircle extends StatelessWidget { + /// Creates a circular shimmer placeholder with the given [size] as diameter. + const UiShimmerCircle({ + super.key, + required this.size, + }); + + /// Diameter of the circle. + final double size; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: const BoxDecoration( + color: UiColors.white, + shape: BoxShape.circle, + ), + ); + } +} + +/// Text-line shimmer placeholder with configurable width and height. +/// +/// Useful for simulating a single line of text. Renders as a solid white +/// rounded rectangle; the parent [UiShimmer] applies the animated gradient. +class UiShimmerLine extends StatelessWidget { + /// Creates a text-line shimmer placeholder. + const UiShimmerLine({ + super.key, + this.width = double.infinity, + this.height = 14, + }); + + /// Width of the line. Defaults to [double.infinity]. + final double width; + + /// Height of the line. Defaults to 14 logical pixels. + final double height; + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusSm, + ), + ); + } +} diff --git a/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart b/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart index 445e8141..430d163d 100644 --- a/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart +++ b/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart @@ -1,8 +1,6 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import '../ui_constants.dart'; - /// A customizable notice banner widget for displaying informational messages. /// /// [UiNoticeBanner] displays a message with an optional icon and supports diff --git a/apps/mobile/packages/design_system/pubspec.yaml b/apps/mobile/packages/design_system/pubspec.yaml index 0979764c..1153026d 100644 --- a/apps/mobile/packages/design_system/pubspec.yaml +++ b/apps/mobile/packages/design_system/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: google_fonts: ^7.0.2 lucide_icons: ^0.257.0 font_awesome_flutter: ^10.7.0 + shimmer: ^3.0.0 dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/staff_payment.dart b/apps/mobile/packages/domain/lib/src/entities/financial/staff_payment.dart index d6126de8..75cd8d8e 100644 --- a/apps/mobile/packages/domain/lib/src/entities/financial/staff_payment.dart +++ b/apps/mobile/packages/domain/lib/src/entities/financial/staff_payment.dart @@ -28,6 +28,12 @@ class StaffPayment extends Equatable { required this.amount, required this.status, this.paidAt, + this.shiftTitle, + this.shiftLocation, + this.locationAddress, + this.hoursWorked, + this.hourlyRate, + this.workedTime, }); /// Unique identifier. final String id; @@ -47,6 +53,24 @@ class StaffPayment extends Equatable { /// When the payment was successfully processed. final DateTime? paidAt; + /// Title of the shift worked. + final String? shiftTitle; + + /// Location/hub name of the shift. + final String? shiftLocation; + + /// Address of the shift location. + final String? locationAddress; + + /// Number of hours worked. + final double? hoursWorked; + + /// Hourly rate for the shift. + final double? hourlyRate; + + /// Work session duration or status. + final String? workedTime; + @override - List get props => [id, staffId, assignmentId, amount, status, paidAt]; + List get props => [id, staffId, assignmentId, amount, status, paidAt, shiftTitle, shiftLocation, locationAddress, hoursWorked, hourlyRate, workedTime]; } \ No newline at end of file diff --git a/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart b/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart index 9ad44e3e..b2bf37d8 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart @@ -24,20 +24,20 @@ class BillingModule extends Module { @override void binds(Injector i) { // Repositories - i.addSingleton(BillingRepositoryImpl.new); + i.addLazySingleton(BillingRepositoryImpl.new); // Use Cases - i.addSingleton(GetBankAccountsUseCase.new); - i.addSingleton(GetCurrentBillAmountUseCase.new); - i.addSingleton(GetSavingsAmountUseCase.new); - i.addSingleton(GetPendingInvoicesUseCase.new); - i.addSingleton(GetInvoiceHistoryUseCase.new); - i.addSingleton(GetSpendingBreakdownUseCase.new); - i.addSingleton(ApproveInvoiceUseCase.new); - i.addSingleton(DisputeInvoiceUseCase.new); + i.addLazySingleton(GetBankAccountsUseCase.new); + i.addLazySingleton(GetCurrentBillAmountUseCase.new); + i.addLazySingleton(GetSavingsAmountUseCase.new); + i.addLazySingleton(GetPendingInvoicesUseCase.new); + i.addLazySingleton(GetInvoiceHistoryUseCase.new); + i.addLazySingleton(GetSpendingBreakdownUseCase.new); + i.addLazySingleton(ApproveInvoiceUseCase.new); + i.addLazySingleton(DisputeInvoiceUseCase.new); // BLoCs - i.addSingleton( + i.addLazySingleton( () => BillingBloc( getBankAccounts: i.get(), getCurrentBillAmount: i.get(), diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart index f7a80aab..ad47a9cf 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart @@ -8,6 +8,7 @@ import 'package:krow_core/core.dart'; import '../blocs/billing_bloc.dart'; import '../blocs/billing_event.dart'; import '../blocs/billing_state.dart'; +import '../widgets/billing_page_skeleton.dart'; import '../widgets/invoice_history_section.dart'; import '../widgets/pending_invoices_section.dart'; import '../widgets/spending_breakdown_card.dart'; @@ -179,10 +180,7 @@ class _BillingViewState extends State { Widget _buildContent(BuildContext context, BillingState state) { if (state.status == BillingStatus.loading) { - return const Padding( - padding: EdgeInsets.all(UiConstants.space10), - child: Center(child: CircularProgressIndicator()), - ); + return const BillingPageSkeleton(); } if (state.status == BillingStatus.failure) { diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart index 430b5193..d7620b3b 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart @@ -7,6 +7,7 @@ import '../blocs/billing_bloc.dart'; import '../blocs/billing_event.dart'; import '../blocs/billing_state.dart'; import '../models/billing_invoice_model.dart'; +import '../widgets/invoices_list_skeleton.dart'; class InvoiceReadyPage extends StatelessWidget { const InvoiceReadyPage({super.key}); @@ -30,7 +31,7 @@ class InvoiceReadyView extends StatelessWidget { body: BlocBuilder( builder: (BuildContext context, BillingState state) { if (state.status == BillingStatus.loading) { - return const Center(child: CircularProgressIndicator()); + return const InvoicesListSkeleton(); } if (state.invoiceHistory.isEmpty) { diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart index d76b6d1a..3b29c4b5 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart @@ -7,6 +7,7 @@ import 'package:krow_core/core.dart'; import '../blocs/billing_bloc.dart'; import '../blocs/billing_state.dart'; +import '../widgets/invoices_list_skeleton.dart'; import '../widgets/pending_invoices_section.dart'; class PendingInvoicesPage extends StatelessWidget { @@ -31,7 +32,7 @@ class PendingInvoicesPage extends StatelessWidget { Widget _buildBody(BuildContext context, BillingState state) { if (state.status == BillingStatus.loading) { - return const Center(child: CircularProgressIndicator()); + return const InvoicesListSkeleton(); } if (state.pendingInvoices.isEmpty) { diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton.dart new file mode 100644 index 00000000..398b9434 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton.dart @@ -0,0 +1 @@ +export 'billing_page_skeleton/index.dart'; diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/billing_page_skeleton.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/billing_page_skeleton.dart new file mode 100644 index 00000000..e4d41037 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/billing_page_skeleton.dart @@ -0,0 +1,67 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'breakdown_row_skeleton.dart'; +import 'invoice_card_skeleton.dart'; + +/// Shimmer loading skeleton for the billing page content area. +/// +/// Mimics the loaded layout with a pending invoices section, +/// a spending breakdown card, and an invoice history list. +class BillingPageSkeleton extends StatelessWidget { + /// Creates a [BillingPageSkeleton]. + const BillingPageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Pending invoices section header + const UiShimmerSectionHeader(), + const SizedBox(height: UiConstants.space3), + + // Pending invoice cards + const InvoiceCardSkeleton(), + const SizedBox(height: UiConstants.space4), + const InvoiceCardSkeleton(), + const SizedBox(height: UiConstants.space6), + + // Spending breakdown card + Container( + padding: const EdgeInsets.all(UiConstants.space5), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 160, height: 16), + SizedBox(height: UiConstants.space4), + // Breakdown rows + BreakdownRowSkeleton(), + SizedBox(height: UiConstants.space3), + BreakdownRowSkeleton(), + SizedBox(height: UiConstants.space3), + BreakdownRowSkeleton(), + ], + ), + ), + const SizedBox(height: UiConstants.space6), + + // Invoice history section header + const UiShimmerSectionHeader(), + const SizedBox(height: UiConstants.space3), + const UiShimmerListItem(), + const UiShimmerListItem(), + const UiShimmerListItem(), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/breakdown_row_skeleton.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/breakdown_row_skeleton.dart new file mode 100644 index 00000000..978b5f38 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/breakdown_row_skeleton.dart @@ -0,0 +1,19 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a spending breakdown row. +class BreakdownRowSkeleton extends StatelessWidget { + /// Creates a [BreakdownRowSkeleton]. + const BreakdownRowSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + UiShimmerLine(width: 100, height: 14), + UiShimmerLine(width: 60, height: 14), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/index.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/index.dart new file mode 100644 index 00000000..d803d599 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/index.dart @@ -0,0 +1,3 @@ +export 'billing_page_skeleton.dart'; +export 'breakdown_row_skeleton.dart'; +export 'invoice_card_skeleton.dart'; diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/invoice_card_skeleton.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/invoice_card_skeleton.dart new file mode 100644 index 00000000..e86811db --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/invoice_card_skeleton.dart @@ -0,0 +1,58 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single pending invoice card. +class InvoiceCardSkeleton extends StatelessWidget { + /// Creates an [InvoiceCardSkeleton]. + const InvoiceCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + UiShimmerBox( + width: 72, + height: 24, + borderRadius: UiConstants.radiusFull, + ), + const UiShimmerLine(width: 80, height: 12), + ], + ), + const SizedBox(height: UiConstants.space4), + const UiShimmerLine(width: 200, height: 16), + const SizedBox(height: UiConstants.space2), + const UiShimmerLine(width: 160, height: 12), + const SizedBox(height: UiConstants.space4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 80, height: 10), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 100, height: 18), + ], + ), + UiShimmerBox( + width: 100, + height: 36, + borderRadius: UiConstants.radiusMd, + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoices_list_skeleton.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoices_list_skeleton.dart new file mode 100644 index 00000000..42bc6543 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoices_list_skeleton.dart @@ -0,0 +1,75 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer loading skeleton for invoice list pages. +/// +/// Used by both [PendingInvoicesPage] and [InvoiceReadyPage] to show +/// placeholder cards while data loads. +class InvoicesListSkeleton extends StatelessWidget { + /// Creates an [InvoicesListSkeleton]. + const InvoicesListSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + children: List.generate(4, (int index) { + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space4), + child: Container( + padding: const EdgeInsets.all(UiConstants.space5), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + UiShimmerBox( + width: 64, + height: 22, + borderRadius: UiConstants.radiusFull, + ), + const UiShimmerLine(width: 80, height: 12), + ], + ), + const SizedBox(height: UiConstants.space4), + const UiShimmerLine(width: 180, height: 16), + const SizedBox(height: UiConstants.space2), + const UiShimmerLine(width: 140, height: 12), + const SizedBox(height: UiConstants.space4), + const Divider(color: UiColors.border), + const SizedBox(height: UiConstants.space3), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 80, height: 10), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 100, height: 20), + ], + ), + UiShimmerBox( + width: 100, + height: 36, + borderRadius: UiConstants.radiusMd, + ), + ], + ), + ], + ), + ), + ); + }), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/coverage_module.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/coverage_module.dart index aa36826c..cd741711 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/coverage_module.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/coverage_module.dart @@ -16,14 +16,14 @@ class CoverageModule extends Module { @override void binds(Injector i) { // Repositories - i.addSingleton(CoverageRepositoryImpl.new); + i.addLazySingleton(CoverageRepositoryImpl.new); // Use Cases - i.addSingleton(GetShiftsForDateUseCase.new); - i.addSingleton(GetCoverageStatsUseCase.new); + i.addLazySingleton(GetShiftsForDateUseCase.new); + i.addLazySingleton(GetCoverageStatsUseCase.new); // BLoCs - i.addSingleton(CoverageBloc.new); + i.addLazySingleton(CoverageBloc.new); } @override diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart index 697fc13d..529bd360 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart @@ -1,17 +1,18 @@ +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:intl/intl.dart'; -import 'package:krow_core/core.dart'; -import 'package:core_localization/core_localization.dart'; + import '../blocs/coverage_bloc.dart'; import '../blocs/coverage_event.dart'; import '../blocs/coverage_state.dart'; - import '../widgets/coverage_calendar_selector.dart'; +import '../widgets/coverage_page_skeleton.dart'; import '../widgets/coverage_quick_stats.dart'; import '../widgets/coverage_shift_list.dart'; +import '../widgets/coverage_stats_header.dart'; import '../widgets/late_workers_alert.dart'; /// Page for displaying daily coverage information. @@ -60,7 +61,8 @@ class _CoveragePageState extends State { child: Scaffold( body: BlocConsumer( listener: (BuildContext context, CoverageState state) { - if (state.status == CoverageStatus.failure && state.errorMessage != null) { + if (state.status == CoverageStatus.failure && + state.errorMessage != null) { UiSnackbar.show( context, message: translateErrorKey(state.errorMessage!), @@ -78,27 +80,12 @@ class _CoveragePageState extends State { pinned: true, expandedHeight: 300.0, backgroundColor: UiColors.primary, - leading: IconButton( - onPressed: () => Modular.to.toClientHome(), - icon: Container( - padding: const EdgeInsets.all(UiConstants.space2), - decoration: BoxDecoration( - color: UiColors.primaryForeground.withOpacity(0.2), - shape: BoxShape.circle, - ), - child: const Icon( - UiIcons.arrowLeft, - color: UiColors.primaryForeground, - size: UiConstants.space4, - ), - ), - ), title: AnimatedSwitcher( duration: const Duration(milliseconds: 200), child: Text( _isScrolled ? DateFormat('MMMM d').format(selectedDate) - : 'Daily Coverage', + : context.t.client_coverage.page.daily_coverage, key: ValueKey(_isScrolled), style: UiTypography.title2m.copyWith( color: UiColors.primaryForeground, @@ -115,7 +102,7 @@ class _CoveragePageState extends State { icon: Container( padding: const EdgeInsets.all(UiConstants.space2), decoration: BoxDecoration( - color: UiColors.primaryForeground.withOpacity(0.2), + color: UiColors.primaryForeground.withValues(alpha: 0.2), borderRadius: UiConstants.radiusMd, ), child: const Icon( @@ -158,57 +145,13 @@ class _CoveragePageState extends State { }, ), const SizedBox(height: UiConstants.space4), - // Coverage Stats Container - Container( - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - color: - UiColors.primaryForeground.withOpacity(0.1), - borderRadius: UiConstants.radiusLg, - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - 'Coverage Status', - style: UiTypography.body2r.copyWith( - color: UiColors.primaryForeground - .withOpacity(0.7), - ), - ), - Text( - '${state.stats?.coveragePercent ?? 0}%', - style: UiTypography.display1b.copyWith( - color: UiColors.primaryForeground, - ), - ), - ], - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - 'Workers', - style: UiTypography.body2r.copyWith( - color: UiColors.primaryForeground - .withOpacity(0.7), - ), - ), - Text( - '${state.stats?.totalConfirmed ?? 0}/${state.stats?.totalNeeded ?? 0}', - style: UiTypography.title2m.copyWith( - color: UiColors.primaryForeground, - ), - ), - ], - ), - ], - ), + CoverageStatsHeader( + coveragePercent: + (state.stats?.coveragePercent ?? 0) + .toDouble(), + totalConfirmed: + state.stats?.totalConfirmed ?? 0, + totalNeeded: state.stats?.totalNeeded ?? 0, ), ], ), @@ -238,9 +181,7 @@ class _CoveragePageState extends State { }) { if (state.shifts.isEmpty) { if (state.status == CoverageStatus.loading) { - return const Center( - child: CircularProgressIndicator(), - ); + return const CoveragePageSkeleton(); } if (state.status == CoverageStatus.failure) { @@ -259,16 +200,16 @@ class _CoveragePageState extends State { Text( state.errorMessage != null ? translateErrorKey(state.errorMessage!) - : 'An error occurred', + : context.t.client_coverage.page.error_occurred, style: UiTypography.body1m.textError, textAlign: TextAlign.center, ), const SizedBox(height: UiConstants.space4), UiButton.secondary( - text: 'Retry', + text: context.t.client_coverage.page.retry, onPressed: () => BlocProvider.of(context).add( - const CoverageRefreshRequested(), - ), + const CoverageRefreshRequested(), + ), ), ], ), @@ -281,22 +222,25 @@ class _CoveragePageState extends State { padding: const EdgeInsets.all(UiConstants.space5), child: Column( crossAxisAlignment: CrossAxisAlignment.start, + spacing: UiConstants.space6, children: [ - if (state.stats != null) ...[ - CoverageQuickStats(stats: state.stats!), - const SizedBox(height: UiConstants.space5), - ], - if (state.stats != null && state.stats!.late > 0) ...[ - LateWorkersAlert(lateCount: state.stats!.late), - const SizedBox(height: UiConstants.space5), - ], + Column( + spacing: UiConstants.space2, + children: [ + if (state.stats != null && state.stats!.late > 0) ...[ + LateWorkersAlert(lateCount: state.stats!.late), + ], + if (state.stats != null) ...[ + CoverageQuickStats(stats: state.stats!), + ], + ], + ), Text( - 'Shifts (${state.shifts.length})', + '${context.t.client_coverage.page.shifts} (${state.shifts.length})', style: UiTypography.title2b.copyWith( color: UiColors.textPrimary, ), ), - const SizedBox(height: UiConstants.space3), CoverageShiftList(shifts: state.shifts), const SizedBox( height: UiConstants.space24, diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/calendar_nav_button.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/calendar_nav_button.dart new file mode 100644 index 00000000..c2fa4a94 --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/calendar_nav_button.dart @@ -0,0 +1,41 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Navigation button used in the calendar selector for week navigation. +class CalendarNavButton extends StatelessWidget { + /// Creates a [CalendarNavButton]. + const CalendarNavButton({ + required this.text, + required this.onTap, + super.key, + }); + + /// The button label text. + final String text; + + /// Callback when the button is tapped. + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space3, + vertical: UiConstants.space1, + ), + decoration: BoxDecoration( + color: UiColors.primaryForeground.withValues(alpha: 0.2), + borderRadius: UiConstants.radiusMd, + ), + child: Text( + text, + style: UiTypography.body3r.copyWith( + color: UiColors.primaryForeground, + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_badge.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_badge.dart new file mode 100644 index 00000000..12dbcdcd --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_badge.dart @@ -0,0 +1,59 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Badge showing worker count ratio with color-coded coverage status. +/// +/// Green for 100%+, yellow for 80%+, red below 80%. +class CoverageBadge extends StatelessWidget { + /// Creates a [CoverageBadge]. + const CoverageBadge({ + required this.current, + required this.total, + required this.coveragePercent, + super.key, + }); + + /// Current number of assigned workers. + final int current; + + /// Total workers needed. + final int total; + + /// Coverage percentage used to determine badge color. + final int coveragePercent; + + @override + Widget build(BuildContext context) { + Color bg; + Color text; + + if (coveragePercent >= 100) { + bg = UiColors.textSuccess.withAlpha(40); + text = UiColors.textSuccess; + } else if (coveragePercent >= 80) { + bg = UiColors.textWarning.withAlpha(40); + text = UiColors.textWarning; + } else { + bg = UiColors.destructive.withAlpha(40); + text = UiColors.destructive; + } + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2 + UiConstants.space1, + vertical: UiConstants.space1 / 2, + ), + decoration: BoxDecoration( + color: bg, + border: Border.all(color: text, width: 0.75), + borderRadius: UiConstants.radiusMd, + ), + child: Text( + '$current/$total', + style: UiTypography.body3b.copyWith( + color: text, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_calendar_selector.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_calendar_selector.dart index a5e7787e..f0518e1e 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_calendar_selector.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_calendar_selector.dart @@ -1,7 +1,10 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'calendar_nav_button.dart'; + /// Calendar selector widget for choosing dates. /// /// Displays a week view with navigation buttons and date selection. @@ -74,16 +77,16 @@ class _CoverageCalendarSelectorState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _NavButton( - text: '← Prev Week', + CalendarNavButton( + text: context.t.client_coverage.calendar.prev_week, onTap: _navigatePrevWeek, ), - _NavButton( - text: 'Today', + CalendarNavButton( + text: context.t.client_coverage.calendar.today, onTap: _navigateToday, ), - _NavButton( - text: 'Next Week →', + CalendarNavButton( + text: context.t.client_coverage.calendar.next_week, onTap: _navigateNextWeek, ), ], @@ -145,41 +148,3 @@ class _CoverageCalendarSelectorState extends State { ); } } - -/// Navigation button for calendar navigation. -class _NavButton extends StatelessWidget { - /// Creates a [_NavButton]. - const _NavButton({ - required this.text, - required this.onTap, - }); - - /// The button text. - final String text; - - /// Callback when tapped. - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space3, - vertical: UiConstants.space1, - ), - decoration: BoxDecoration( - color: UiColors.primaryForeground.withOpacity(0.2), - borderRadius: UiConstants.radiusMd, - ), - child: Text( - text, - style: UiTypography.body3r.copyWith( - color: UiColors.primaryForeground, - ), - ), - ), - ); - } -} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_header.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_header.dart deleted file mode 100644 index 7b23f2a9..00000000 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_header.dart +++ /dev/null @@ -1,177 +0,0 @@ -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 'coverage_calendar_selector.dart'; - -/// Header widget for the coverage page. -/// -/// Displays: -/// - Back button and title -/// - Refresh button -/// - Calendar date selector -/// - Coverage summary statistics -class CoverageHeader extends StatelessWidget { - /// Creates a [CoverageHeader]. - const CoverageHeader({ - required this.selectedDate, - required this.coveragePercent, - required this.totalConfirmed, - required this.totalNeeded, - required this.onDateSelected, - required this.onRefresh, - super.key, - }); - - /// The currently selected date. - final DateTime selectedDate; - - /// The coverage percentage. - final int coveragePercent; - - /// The total number of confirmed workers. - final int totalConfirmed; - - /// The total number of workers needed. - final int totalNeeded; - - /// Callback when a date is selected. - final ValueChanged onDateSelected; - - /// Callback when refresh is requested. - final VoidCallback onRefresh; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.only( - top: UiConstants.space14, - left: UiConstants.space5, - right: UiConstants.space5, - bottom: UiConstants.space6, - ), - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [ - UiColors.primary, - UiColors.primary, - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - GestureDetector( - onTap: () => Modular.to.toClientHome(), - child: Container( - width: UiConstants.space10, - height: UiConstants.space10, - decoration: BoxDecoration( - color: UiColors.primaryForeground.withValues(alpha: 0.2), - shape: BoxShape.circle, - ), - child: const Icon( - UiIcons.arrowLeft, - color: UiColors.primaryForeground, - size: UiConstants.space5, - ), - ), - ), - const SizedBox(width: UiConstants.space3), - Text( - 'Daily Coverage', - style: UiTypography.title1m.copyWith( - color: UiColors.primaryForeground, - ), - ), - ], - ), - Container( - width: UiConstants.space8, - height: UiConstants.space8, - decoration: BoxDecoration( - color: UiColors.transparent, - borderRadius: UiConstants.radiusMd, - ), - child: IconButton( - onPressed: onRefresh, - icon: const Icon( - UiIcons.rotateCcw, - color: UiColors.primaryForeground, - size: UiConstants.space4, - ), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - style: IconButton.styleFrom( - hoverColor: UiColors.primaryForeground.withValues(alpha: 0.2), - shape: RoundedRectangleBorder( - borderRadius: UiConstants.radiusMd, - ), - ), - ), - ), - ], - ), - const SizedBox(height: UiConstants.space4), - CoverageCalendarSelector( - selectedDate: selectedDate, - onDateSelected: onDateSelected, - ), - const SizedBox(height: UiConstants.space4), - Container( - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - color: UiColors.primaryForeground.withValues(alpha: 0.1), - borderRadius: UiConstants.radiusLg, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Coverage Status', - style: UiTypography.body2r.copyWith( - color: UiColors.primaryForeground.withValues(alpha: 0.7), - ), - ), - Text( - '$coveragePercent%', - style: UiTypography.display1b.copyWith( - color: UiColors.primaryForeground, - ), - ), - ], - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - 'Workers', - style: UiTypography.body2r.copyWith( - color: UiColors.primaryForeground.withValues(alpha: 0.7), - ), - ), - Text( - '$totalConfirmed/$totalNeeded', - style: UiTypography.title2m.copyWith( - color: UiColors.primaryForeground, - ), - ), - ], - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton.dart new file mode 100644 index 00000000..04c499bf --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton.dart @@ -0,0 +1 @@ +export 'coverage_page_skeleton/index.dart'; diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/coverage_page_skeleton.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/coverage_page_skeleton.dart new file mode 100644 index 00000000..bfb12d31 --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/coverage_page_skeleton.dart @@ -0,0 +1,47 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'shift_card_skeleton.dart'; + +/// Shimmer loading skeleton that mimics the coverage page loaded layout. +/// +/// Shows placeholder shapes for the quick stats row, shift section header, +/// and a list of shift cards with worker rows. +class CoveragePageSkeleton extends StatelessWidget { + /// Creates a [CoveragePageSkeleton]. + const CoveragePageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Quick stats row (2 stat cards) + const Row( + children: [ + Expanded(child: UiShimmerStatsCard()), + SizedBox(width: UiConstants.space2), + Expanded(child: UiShimmerStatsCard()), + ], + ), + const SizedBox(height: UiConstants.space6), + + // Shifts section header + const UiShimmerLine(width: 140, height: 18), + const SizedBox(height: UiConstants.space6), + + // Shift cards with worker rows + const ShiftCardSkeleton(), + const SizedBox(height: UiConstants.space3), + const ShiftCardSkeleton(), + const SizedBox(height: UiConstants.space3), + const ShiftCardSkeleton(), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/index.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/index.dart new file mode 100644 index 00000000..ddac4e8b --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/index.dart @@ -0,0 +1,2 @@ +export 'coverage_page_skeleton.dart'; +export 'shift_card_skeleton.dart'; diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/shift_card_skeleton.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/shift_card_skeleton.dart new file mode 100644 index 00000000..c74212cd --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/shift_card_skeleton.dart @@ -0,0 +1,60 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single shift card with header and worker rows. +class ShiftCardSkeleton extends StatelessWidget { + /// Creates a [ShiftCardSkeleton]. + const ShiftCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + // Shift header + Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const UiShimmerLine(width: 180, height: 16), + const SizedBox(height: UiConstants.space2), + const UiShimmerLine(width: 120, height: 12), + const SizedBox(height: UiConstants.space2), + Row( + children: [ + const UiShimmerLine(width: 80, height: 12), + const Spacer(), + UiShimmerBox( + width: 60, + height: 24, + borderRadius: UiConstants.radiusFull, + ), + ], + ), + ], + ), + ), + + // Worker rows + Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space3, + ).copyWith(bottom: UiConstants.space3), + child: const Column( + children: [ + UiShimmerListItem(), + UiShimmerListItem(), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart index e2b90af2..7ae538b9 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart @@ -1,10 +1,13 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'coverage_stat_card.dart'; + /// Quick statistics cards showing coverage metrics. /// -/// Displays checked-in, en-route, and late worker counts. +/// Displays checked-in and en-route worker counts. class CoverageQuickStats extends StatelessWidget { /// Creates a [CoverageQuickStats]. const CoverageQuickStats({ @@ -18,96 +21,25 @@ class CoverageQuickStats extends StatelessWidget { @override Widget build(BuildContext context) { return Row( + spacing: UiConstants.space2, children: [ Expanded( - child: _StatCard( + child: CoverageStatCard( icon: UiIcons.success, - label: 'Checked In', + label: context.t.client_coverage.stats.checked_in, value: stats.checkedIn.toString(), color: UiColors.iconSuccess, ), ), - const SizedBox(width: UiConstants.space3), Expanded( - child: _StatCard( + child: CoverageStatCard( icon: UiIcons.clock, - label: 'En Route', + label: context.t.client_coverage.stats.en_route, value: stats.enRoute.toString(), color: UiColors.textWarning, ), ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: _StatCard( - icon: UiIcons.warning, - label: 'Late', - value: stats.late.toString(), - color: UiColors.destructive, - ), - ), ], ); } } - -/// Individual stat card widget. -class _StatCard extends StatelessWidget { - /// Creates a [_StatCard]. - const _StatCard({ - required this.icon, - required this.label, - required this.value, - required this.color, - }); - - /// The icon to display. - final IconData icon; - - /// The label text. - final String label; - - /// The value to display. - final String value; - - /// The accent color for the card. - final Color color; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(UiConstants.space3), - decoration: BoxDecoration( - color: color.withAlpha(10), - borderRadius: UiConstants.radiusLg, - border: Border.all( - color: color, - width: 0.75, - ), - ), - child: Column( - children: [ - Icon( - icon, - color: color, - size: UiConstants.space6, - ), - const SizedBox(height: UiConstants.space2), - Text( - value, - style: UiTypography.title1m.copyWith( - color: UiColors.textPrimary, - ), - ), - const SizedBox(height: UiConstants.space1), - Text( - label, - style: UiTypography.body3r.copyWith( - color: UiColors.mutedForeground, - ), - textAlign: TextAlign.center, - ), - ], - ), - ); - } -} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart index e675719b..e70aa5b2 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart @@ -4,6 +4,9 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'shift_header.dart'; +import 'worker_row.dart'; + /// List of shifts with their workers. /// /// Displays all shifts for the selected date, or an empty state if none exist. @@ -33,6 +36,8 @@ class CoverageShiftList extends StatelessWidget { @override Widget build(BuildContext context) { + final TranslationsClientCoverageEn l10n = context.t.client_coverage; + if (shifts.isEmpty) { return Container( padding: const EdgeInsets.all(UiConstants.space8), @@ -51,7 +56,7 @@ class CoverageShiftList extends StatelessWidget { color: UiColors.textSecondary, ), Text( - 'No shifts scheduled for this day', + l10n.no_shifts_day, style: UiTypography.body2r.textSecondary, ), ], @@ -71,7 +76,7 @@ class CoverageShiftList extends StatelessWidget { clipBehavior: Clip.antiAlias, child: Column( children: [ - _ShiftHeader( + ShiftHeader( title: shift.title, location: shift.location, startTime: _formatTime(shift.startTime), @@ -91,7 +96,7 @@ class CoverageShiftList extends StatelessWidget { padding: EdgeInsets.only( bottom: isLast ? 0 : UiConstants.space2, ), - child: _WorkerRow( + child: WorkerRow( worker: worker, shiftStartTime: _formatTime(shift.startTime), formatTime: _formatTime, @@ -104,7 +109,7 @@ class CoverageShiftList extends StatelessWidget { Padding( padding: const EdgeInsets.all(UiConstants.space4), child: Text( - 'No workers assigned yet', + l10n.no_workers_assigned, style: UiTypography.body3r.copyWith( color: UiColors.mutedForeground, ), @@ -117,414 +122,3 @@ class CoverageShiftList extends StatelessWidget { ); } } - -/// Header for a shift card. -class _ShiftHeader extends StatelessWidget { - /// Creates a [_ShiftHeader]. - const _ShiftHeader({ - required this.title, - required this.location, - required this.startTime, - required this.current, - required this.total, - required this.coveragePercent, - required this.shiftId, - }); - - /// The shift title. - final String title; - - /// The shift location. - final String location; - - /// The shift start time. - final String startTime; - - /// Current number of workers. - final int current; - - /// Total workers needed. - final int total; - - /// Coverage percentage. - final int coveragePercent; - - /// The shift ID. - final String shiftId; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(UiConstants.space4), - decoration: const BoxDecoration( - color: UiColors.muted, - border: Border( - bottom: BorderSide( - color: UiColors.border, - ), - ), - ), - child: Row( - spacing: UiConstants.space4, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: UiConstants.space2, - children: [ - Row( - spacing: UiConstants.space2, - children: [ - Container( - width: UiConstants.space2, - height: UiConstants.space2, - decoration: const BoxDecoration( - color: UiColors.primary, - shape: BoxShape.circle, - ), - ), - Text( - title, - style: UiTypography.body1b.textPrimary, - ), - ], - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - spacing: UiConstants.space1, - children: [ - const Icon( - UiIcons.mapPin, - size: UiConstants.space3, - color: UiColors.iconSecondary, - ), - Expanded( - child: Text( - location, - style: UiTypography.body3r.textSecondary, - overflow: TextOverflow.ellipsis, - )), - ], - ), - Row( - spacing: UiConstants.space1, - children: [ - const Icon( - UiIcons.clock, - size: UiConstants.space3, - color: UiColors.iconSecondary, - ), - Text( - startTime, - style: UiTypography.body3r.textSecondary, - ), - ], - ), - ], - ), - ], - ), - ), - _CoverageBadge( - current: current, - total: total, - coveragePercent: coveragePercent, - ), - ], - ), - ); - } -} - -/// Coverage badge showing worker count and status. -class _CoverageBadge extends StatelessWidget { - /// Creates a [_CoverageBadge]. - const _CoverageBadge({ - required this.current, - required this.total, - required this.coveragePercent, - }); - - /// Current number of workers. - final int current; - - /// Total workers needed. - final int total; - - /// Coverage percentage. - final int coveragePercent; - - @override - Widget build(BuildContext context) { - Color bg; - Color text; - - if (coveragePercent >= 100) { - bg = UiColors.textSuccess.withAlpha(40); - text = UiColors.textSuccess; - } else if (coveragePercent >= 80) { - bg = UiColors.textWarning.withAlpha(40); - text = UiColors.textWarning; - } else { - bg = UiColors.destructive.withAlpha(40); - text = UiColors.destructive; - } - - return Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space2 + UiConstants.space1, - vertical: UiConstants.space1 / 2, - ), - decoration: BoxDecoration( - color: bg, - border: Border.all(color: text, width: 0.75), - borderRadius: UiConstants.radiusMd, - ), - child: Text( - '$current/$total', - style: UiTypography.body3b.copyWith( - color: text, - ), - ), - ); - } -} - -/// Row displaying a single worker's status. -class _WorkerRow extends StatelessWidget { - /// Creates a [_WorkerRow]. - const _WorkerRow({ - required this.worker, - required this.shiftStartTime, - required this.formatTime, - }); - - /// The worker to display. - final CoverageWorker worker; - - /// The shift start time. - final String shiftStartTime; - - /// Function to format time strings. - final String Function(String?) formatTime; - - @override - Widget build(BuildContext context) { - Color bg; - Color border; - Color textBg; - Color textColor; - IconData icon; - String statusText; - Color badgeBg; - Color badgeText; - Color badgeBorder; - String badgeLabel; - - switch (worker.status) { - case CoverageWorkerStatus.checkedIn: - bg = UiColors.textSuccess.withAlpha(26); - border = UiColors.textSuccess; - textBg = UiColors.textSuccess.withAlpha(51); - textColor = UiColors.textSuccess; - icon = UiIcons.success; - statusText = '✓ Checked In at ${formatTime(worker.checkInTime)}'; - badgeBg = UiColors.textSuccess.withAlpha(40); - badgeText = UiColors.textSuccess; - badgeBorder = badgeText; - badgeLabel = 'On Site'; - case CoverageWorkerStatus.confirmed: - if (worker.checkInTime == null) { - bg = UiColors.textWarning.withAlpha(26); - border = UiColors.textWarning; - textBg = UiColors.textWarning.withAlpha(51); - textColor = UiColors.textWarning; - icon = UiIcons.clock; - statusText = 'En Route - Expected $shiftStartTime'; - badgeBg = UiColors.textWarning.withAlpha(40); - badgeText = UiColors.textWarning; - badgeBorder = badgeText; - badgeLabel = 'En Route'; - } else { - bg = UiColors.muted.withAlpha(26); - border = UiColors.border; - textBg = UiColors.muted.withAlpha(51); - textColor = UiColors.textSecondary; - icon = UiIcons.success; - statusText = 'Confirmed'; - badgeBg = UiColors.textSecondary.withAlpha(40); - badgeText = UiColors.textSecondary; - badgeBorder = badgeText; - badgeLabel = 'Confirmed'; - } - case CoverageWorkerStatus.late: - bg = UiColors.destructive.withAlpha(26); - border = UiColors.destructive; - textBg = UiColors.destructive.withAlpha(51); - textColor = UiColors.destructive; - icon = UiIcons.warning; - statusText = '⚠ Running Late'; - badgeBg = UiColors.destructive.withAlpha(40); - badgeText = UiColors.destructive; - badgeBorder = badgeText; - badgeLabel = 'Late'; - case CoverageWorkerStatus.checkedOut: - bg = UiColors.muted.withAlpha(26); - border = UiColors.border; - textBg = UiColors.muted.withAlpha(51); - textColor = UiColors.textSecondary; - icon = UiIcons.success; - statusText = 'Checked Out'; - badgeBg = UiColors.textSecondary.withAlpha(40); - badgeText = UiColors.textSecondary; - badgeBorder = badgeText; - badgeLabel = 'Done'; - case CoverageWorkerStatus.noShow: - bg = UiColors.destructive.withAlpha(26); - border = UiColors.destructive; - textBg = UiColors.destructive.withAlpha(51); - textColor = UiColors.destructive; - icon = UiIcons.warning; - statusText = 'No Show'; - badgeBg = UiColors.destructive.withAlpha(40); - badgeText = UiColors.destructive; - badgeBorder = badgeText; - badgeLabel = 'No Show'; - case CoverageWorkerStatus.completed: - bg = UiColors.iconSuccess.withAlpha(26); - border = UiColors.iconSuccess; - textBg = UiColors.iconSuccess.withAlpha(51); - textColor = UiColors.textSuccess; - icon = UiIcons.success; - statusText = 'Completed'; - badgeBg = UiColors.textSuccess.withAlpha(40); - badgeText = UiColors.textSuccess; - badgeBorder = badgeText; - badgeLabel = 'Completed'; - case CoverageWorkerStatus.pending: - case CoverageWorkerStatus.accepted: - case CoverageWorkerStatus.rejected: - bg = UiColors.muted.withAlpha(26); - border = UiColors.border; - textBg = UiColors.muted.withAlpha(51); - textColor = UiColors.textSecondary; - icon = UiIcons.clock; - statusText = worker.status.name.toUpperCase(); - badgeBg = UiColors.textSecondary.withAlpha(40); - badgeText = UiColors.textSecondary; - badgeBorder = badgeText; - badgeLabel = worker.status.name[0].toUpperCase() + - worker.status.name.substring(1); - } - - return Container( - padding: const EdgeInsets.all(UiConstants.space2), - decoration: BoxDecoration( - color: bg, - borderRadius: UiConstants.radiusMd, - ), - child: Row( - children: [ - Stack( - clipBehavior: Clip.none, - children: [ - Container( - width: UiConstants.space10, - height: UiConstants.space10, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: border, width: 2), - ), - child: CircleAvatar( - backgroundColor: textBg, - child: Text( - worker.name.isNotEmpty ? worker.name[0] : 'W', - style: UiTypography.body1b.copyWith( - color: textColor, - ), - ), - ), - ), - Positioned( - bottom: -2, - right: -2, - child: Container( - width: UiConstants.space4, - height: UiConstants.space4, - decoration: BoxDecoration( - color: border, - shape: BoxShape.circle, - ), - child: Icon( - icon, - size: UiConstants.space2 + UiConstants.space1, - color: UiColors.primaryForeground, - ), - ), - ), - ], - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - worker.name, - style: UiTypography.body2b.copyWith( - color: UiColors.textPrimary, - ), - ), - Text( - statusText, - style: UiTypography.body3m.copyWith( - color: textColor, - ), - ), - ], - ), - ), - Column( - spacing: UiConstants.space2, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space2, - vertical: UiConstants.space1 / 2, - ), - decoration: BoxDecoration( - color: badgeBg, - borderRadius: UiConstants.radiusMd, - border: Border.all(color: badgeBorder, width: 0.5), - ), - child: Text( - badgeLabel, - style: UiTypography.footnote2b.copyWith( - color: badgeText, - ), - ), - ), - if (worker.status == CoverageWorkerStatus.checkedIn) - UiButton.primary( - text: context.t.client_coverage.worker_row.verify, - size: UiButtonSize.small, - onPressed: () { - UiSnackbar.show( - context, - message: - context.t.client_coverage.worker_row.verified_message( - name: worker.name, - ), - type: UiSnackbarType.success, - ); - }, - ), - ], - ), - ], - ), - ); - } -} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_stat_card.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_stat_card.dart new file mode 100644 index 00000000..b82585ce --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_stat_card.dart @@ -0,0 +1,64 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Stat card displaying an icon, value, and label with an accent color. +class CoverageStatCard extends StatelessWidget { + /// Creates a [CoverageStatCard]. + const CoverageStatCard({ + required this.icon, + required this.label, + required this.value, + required this.color, + super.key, + }); + + /// The icon to display. + final IconData icon; + + /// The label text describing the stat. + final String label; + + /// The numeric value to display. + final String value; + + /// The accent color for the card border, icon, and text. + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: color.withAlpha(10), + borderRadius: UiConstants.radiusLg, + border: Border.all( + color: color, + width: 0.5, + ), + ), + child: Row( + spacing: UiConstants.space2, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + icon, + color: color, + size: UiConstants.space6, + ), + Text( + value, + style: UiTypography.title1b.copyWith( + color: color, + ), + ), + Text( + label, + style: UiTypography.body3r.copyWith( + color: color, + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_stats_header.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_stats_header.dart new file mode 100644 index 00000000..15b4b448 --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_stats_header.dart @@ -0,0 +1,73 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Displays coverage percentage and worker ratio in the app bar header. +class CoverageStatsHeader extends StatelessWidget { + /// Creates a [CoverageStatsHeader]. + const CoverageStatsHeader({ + required this.coveragePercent, + required this.totalConfirmed, + required this.totalNeeded, + super.key, + }); + + /// The current coverage percentage. + final double coveragePercent; + + /// The number of confirmed workers. + final int totalConfirmed; + + /// The total number of workers needed. + final int totalNeeded; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.primaryForeground.withOpacity(0.1), + borderRadius: UiConstants.radiusLg, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_coverage.page.coverage_status, + style: UiTypography.body2r.copyWith( + color: UiColors.primaryForeground.withOpacity(0.7), + ), + ), + Text( + '${coveragePercent.toStringAsFixed(0)}%', + style: UiTypography.display1b.copyWith( + color: UiColors.primaryForeground, + ), + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + context.t.client_coverage.page.workers, + style: UiTypography.body2r.copyWith( + color: UiColors.primaryForeground.withOpacity(0.7), + ), + ), + Text( + '$totalConfirmed/$totalNeeded', + style: UiTypography.title2m.copyWith( + color: UiColors.primaryForeground, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/late_workers_alert.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/late_workers_alert.dart index c501796a..716512cc 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/late_workers_alert.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/late_workers_alert.dart @@ -1,9 +1,10 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; /// Alert widget for displaying late workers warning. /// -/// Shows a warning banner when there are late workers. +/// Shows a warning banner when workers are running late. class LateWorkersAlert extends StatelessWidget { /// Creates a [LateWorkersAlert]. const LateWorkersAlert({ @@ -22,32 +23,30 @@ class LateWorkersAlert extends StatelessWidget { color: UiColors.destructive.withValues(alpha: 0.1), borderRadius: UiConstants.radiusLg, border: Border.all( - color: UiColors.destructive.withValues(alpha: 0.3), + color: UiColors.destructive, + width: 0.5, ), ), child: Row( + spacing: UiConstants.space4, children: [ const Icon( UiIcons.warning, color: UiColors.destructive, - size: UiConstants.space5, ), - const SizedBox(width: UiConstants.space3), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Late Workers Alert', - style: UiTypography.body1b.copyWith( - color: UiColors.destructive, - ), + context.t.client_coverage.alert + .workers_running_late(n: lateCount, count: lateCount), + style: UiTypography.body1b.textError, ), - const SizedBox(height: UiConstants.space1), Text( - '$lateCount ${lateCount == 1 ? 'worker is' : 'workers are'} running late', + context.t.client_coverage.alert.auto_backup_searching, style: UiTypography.body3r.copyWith( - color: UiColors.destructiveForeground, + color: UiColors.textError.withValues(alpha: 0.7), ), ), ], diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/shift_header.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/shift_header.dart new file mode 100644 index 00000000..d35c49ca --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/shift_header.dart @@ -0,0 +1,125 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'coverage_badge.dart'; + +/// Header section for a shift card showing title, location, time, and coverage. +class ShiftHeader extends StatelessWidget { + /// Creates a [ShiftHeader]. + const ShiftHeader({ + required this.title, + required this.location, + required this.startTime, + required this.current, + required this.total, + required this.coveragePercent, + required this.shiftId, + super.key, + }); + + /// The shift title. + final String title; + + /// The shift location. + final String location; + + /// The formatted shift start time. + final String startTime; + + /// Current number of assigned workers. + final int current; + + /// Total workers needed for the shift. + final int total; + + /// Coverage percentage (0-100+). + final int coveragePercent; + + /// The shift identifier. + final String shiftId; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: const BoxDecoration( + color: UiColors.muted, + border: Border( + bottom: BorderSide( + color: UiColors.border, + ), + ), + ), + child: Row( + spacing: UiConstants.space4, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: UiConstants.space2, + children: [ + Row( + spacing: UiConstants.space2, + children: [ + Container( + width: UiConstants.space2, + height: UiConstants.space2, + decoration: const BoxDecoration( + color: UiColors.primary, + shape: BoxShape.circle, + ), + ), + Text( + title, + style: UiTypography.body1b.textPrimary, + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + spacing: UiConstants.space1, + children: [ + const Icon( + UiIcons.mapPin, + size: UiConstants.space3, + color: UiColors.iconSecondary, + ), + Expanded( + child: Text( + location, + style: UiTypography.body3r.textSecondary, + overflow: TextOverflow.ellipsis, + )), + ], + ), + Row( + spacing: UiConstants.space1, + children: [ + const Icon( + UiIcons.clock, + size: UiConstants.space3, + color: UiColors.iconSecondary, + ), + Text( + startTime, + style: UiTypography.body3r.textSecondary, + ), + ], + ), + ], + ), + ], + ), + ), + CoverageBadge( + current: current, + total: total, + coveragePercent: coveragePercent, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_row.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_row.dart new file mode 100644 index 00000000..25171bc8 --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_row.dart @@ -0,0 +1,231 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Row displaying a single worker's avatar, name, status, and badge. +class WorkerRow extends StatelessWidget { + /// Creates a [WorkerRow]. + const WorkerRow({ + required this.worker, + required this.shiftStartTime, + required this.formatTime, + super.key, + }); + + /// The worker data to display. + final CoverageWorker worker; + + /// The formatted shift start time. + final String shiftStartTime; + + /// Callback to format a raw time string into a readable format. + final String Function(String?) formatTime; + + @override + Widget build(BuildContext context) { + final TranslationsClientCoverageEn l10n = context.t.client_coverage; + + Color bg; + Color border; + Color textBg; + Color textColor; + IconData icon; + String statusText; + Color badgeBg; + Color badgeText; + Color badgeBorder; + String badgeLabel; + + switch (worker.status) { + case CoverageWorkerStatus.checkedIn: + bg = UiColors.textSuccess.withAlpha(26); + border = UiColors.textSuccess; + textBg = UiColors.textSuccess.withAlpha(51); + textColor = UiColors.textSuccess; + icon = UiIcons.success; + statusText = l10n.status_checked_in_at( + time: formatTime(worker.checkInTime), + ); + badgeBg = UiColors.textSuccess.withAlpha(40); + badgeText = UiColors.textSuccess; + badgeBorder = badgeText; + badgeLabel = l10n.status_on_site; + case CoverageWorkerStatus.confirmed: + if (worker.checkInTime == null) { + bg = UiColors.textWarning.withAlpha(26); + border = UiColors.textWarning; + textBg = UiColors.textWarning.withAlpha(51); + textColor = UiColors.textWarning; + icon = UiIcons.clock; + statusText = l10n.status_en_route_expected(time: shiftStartTime); + badgeBg = UiColors.textWarning.withAlpha(40); + badgeText = UiColors.textWarning; + badgeBorder = badgeText; + badgeLabel = l10n.status_en_route; + } else { + bg = UiColors.muted.withAlpha(26); + border = UiColors.border; + textBg = UiColors.muted.withAlpha(51); + textColor = UiColors.textSecondary; + icon = UiIcons.success; + statusText = l10n.status_confirmed; + badgeBg = UiColors.textSecondary.withAlpha(40); + badgeText = UiColors.textSecondary; + badgeBorder = badgeText; + badgeLabel = l10n.status_confirmed; + } + case CoverageWorkerStatus.late: + bg = UiColors.destructive.withAlpha(26); + border = UiColors.destructive; + textBg = UiColors.destructive.withAlpha(51); + textColor = UiColors.destructive; + icon = UiIcons.warning; + statusText = l10n.status_running_late; + badgeBg = UiColors.destructive.withAlpha(40); + badgeText = UiColors.destructive; + badgeBorder = badgeText; + badgeLabel = l10n.status_late; + case CoverageWorkerStatus.checkedOut: + bg = UiColors.muted.withAlpha(26); + border = UiColors.border; + textBg = UiColors.muted.withAlpha(51); + textColor = UiColors.textSecondary; + icon = UiIcons.success; + statusText = l10n.status_checked_out; + badgeBg = UiColors.textSecondary.withAlpha(40); + badgeText = UiColors.textSecondary; + badgeBorder = badgeText; + badgeLabel = l10n.status_done; + case CoverageWorkerStatus.noShow: + bg = UiColors.destructive.withAlpha(26); + border = UiColors.destructive; + textBg = UiColors.destructive.withAlpha(51); + textColor = UiColors.destructive; + icon = UiIcons.warning; + statusText = l10n.status_no_show; + badgeBg = UiColors.destructive.withAlpha(40); + badgeText = UiColors.destructive; + badgeBorder = badgeText; + badgeLabel = l10n.status_no_show; + case CoverageWorkerStatus.completed: + bg = UiColors.iconSuccess.withAlpha(26); + border = UiColors.iconSuccess; + textBg = UiColors.iconSuccess.withAlpha(51); + textColor = UiColors.textSuccess; + icon = UiIcons.success; + statusText = l10n.status_completed; + badgeBg = UiColors.textSuccess.withAlpha(40); + badgeText = UiColors.textSuccess; + badgeBorder = badgeText; + badgeLabel = l10n.status_completed; + case CoverageWorkerStatus.pending: + case CoverageWorkerStatus.accepted: + case CoverageWorkerStatus.rejected: + bg = UiColors.muted.withAlpha(26); + border = UiColors.border; + textBg = UiColors.muted.withAlpha(51); + textColor = UiColors.textSecondary; + icon = UiIcons.clock; + statusText = worker.status.name.toUpperCase(); + badgeBg = UiColors.textSecondary.withAlpha(40); + badgeText = UiColors.textSecondary; + badgeBorder = badgeText; + badgeLabel = worker.status.name[0].toUpperCase() + + worker.status.name.substring(1); + } + + return Container( + padding: const EdgeInsets.all(UiConstants.space2), + decoration: BoxDecoration( + color: bg, + borderRadius: UiConstants.radiusMd, + ), + child: Row( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: UiConstants.space10, + height: UiConstants.space10, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: border, width: 2), + ), + child: CircleAvatar( + backgroundColor: textBg, + child: Text( + worker.name.isNotEmpty ? worker.name[0] : 'W', + style: UiTypography.body1b.copyWith( + color: textColor, + ), + ), + ), + ), + Positioned( + bottom: -2, + right: -2, + child: Container( + width: UiConstants.space4, + height: UiConstants.space4, + decoration: BoxDecoration( + color: border, + shape: BoxShape.circle, + ), + child: Icon( + icon, + size: UiConstants.space2 + UiConstants.space1, + color: UiColors.primaryForeground, + ), + ), + ), + ], + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + worker.name, + style: UiTypography.body2b.copyWith( + color: UiColors.textPrimary, + ), + ), + Text( + statusText, + style: UiTypography.body3m.copyWith( + color: textColor, + ), + ), + ], + ), + ), + Column( + spacing: UiConstants.space2, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: UiConstants.space1 / 2, + ), + decoration: BoxDecoration( + color: badgeBg, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: badgeBorder, width: 0.5), + ), + child: Text( + badgeLabel, + style: UiTypography.footnote2b.copyWith( + color: badgeText, + ), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart b/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart index 24762388..1204f1e9 100644 --- a/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart +++ b/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart @@ -13,7 +13,7 @@ import 'presentation/pages/client_main_page.dart'; class ClientMainModule extends Module { @override void binds(Injector i) { - i.addSingleton(ClientMainCubit.new); + i.addLazySingleton(ClientMainCubit.new); } @override diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_body.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_body.dart index 06e65c95..9b39ec2f 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_body.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_body.dart @@ -8,10 +8,12 @@ import '../blocs/client_home_state.dart'; import 'client_home_edit_mode_body.dart'; import 'client_home_error_state.dart'; import 'client_home_normal_mode_body.dart'; +import 'client_home_page_skeleton.dart'; /// Main body widget for the client home page. /// -/// Manages the state transitions between error, edit mode, and normal mode views. +/// Manages the state transitions between loading, error, edit mode, +/// and normal mode views. class ClientHomeBody extends StatelessWidget { /// Creates a [ClientHomeBody]. const ClientHomeBody({super.key}); @@ -31,6 +33,10 @@ class ClientHomeBody extends StatelessWidget { } }, builder: (BuildContext context, ClientHomeState state) { + if (state.status == ClientHomeStatus.initial || + state.status == ClientHomeStatus.loading) { + return const ClientHomePageSkeleton(); + } if (state.status == ClientHomeStatus.error) { return ClientHomeErrorState(state: state); } diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart index bcfe0d31..0a1f4489 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart @@ -22,8 +22,15 @@ class ClientHomeEditBanner extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder( - buildWhen: (ClientHomeState prev, ClientHomeState curr) => prev.isEditMode != curr.isEditMode, + buildWhen: (ClientHomeState prev, ClientHomeState curr) => + prev.isEditMode != curr.isEditMode || + prev.status != curr.status, builder: (BuildContext context, ClientHomeState state) { + if (state.status == ClientHomeStatus.initial || + state.status == ClientHomeStatus.loading) { + return const SizedBox.shrink(); + } + return AnimatedContainer( duration: const Duration(milliseconds: 300), height: state.isEditMode ? 80 : 0, diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_header.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_header.dart index a756594c..683793c8 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_header.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_header.dart @@ -7,6 +7,7 @@ import '../blocs/client_home_bloc.dart'; import '../blocs/client_home_event.dart'; import '../blocs/client_home_state.dart'; import 'header_icon_button.dart'; +import 'client_home_header_skeleton.dart'; /// The header section of the client home page. /// @@ -26,6 +27,11 @@ class ClientHomeHeader extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (BuildContext context, ClientHomeState state) { + if (state.status == ClientHomeStatus.initial || + state.status == ClientHomeStatus.loading) { + return const ClientHomeHeaderSkeleton(); + } + final String businessName = state.businessName; final String? photoUrl = state.photoUrl; final String avatarLetter = businessName.trim().isNotEmpty diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_header_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_header_skeleton.dart new file mode 100644 index 00000000..2e186863 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_header_skeleton.dart @@ -0,0 +1,50 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the client home header during loading. +/// +/// Mimics the avatar, welcome text, business name, and action buttons. +class ClientHomeHeaderSkeleton extends StatelessWidget { + /// Creates a [ClientHomeHeaderSkeleton]. + const ClientHomeHeaderSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: Padding( + padding: const EdgeInsets.fromLTRB( + UiConstants.space4, + UiConstants.space4, + UiConstants.space4, + UiConstants.space3, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const UiShimmerCircle(size: UiConstants.space10), + const SizedBox(width: UiConstants.space3), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + UiShimmerLine(width: 80, height: 12), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 120, height: 16), + ], + ), + ], + ), + Row( + spacing: UiConstants.space2, + children: const [ + UiShimmerBox(width: 36, height: 36), + UiShimmerBox(width: 36, height: 36), + ], + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton.dart new file mode 100644 index 00000000..c293fca1 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton.dart @@ -0,0 +1,10 @@ +export 'client_home_page_skeleton/action_card_skeleton.dart'; +export 'client_home_page_skeleton/actions_section_skeleton.dart'; +export 'client_home_page_skeleton/client_home_page_skeleton.dart'; +export 'client_home_page_skeleton/coverage_section_skeleton.dart'; +export 'client_home_page_skeleton/live_activity_section_skeleton.dart'; +export 'client_home_page_skeleton/metric_card_skeleton.dart'; +export 'client_home_page_skeleton/reorder_card_skeleton.dart'; +export 'client_home_page_skeleton/reorder_section_skeleton.dart'; +export 'client_home_page_skeleton/spending_card_skeleton.dart'; +export 'client_home_page_skeleton/spending_section_skeleton.dart'; diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/action_card_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/action_card_skeleton.dart new file mode 100644 index 00000000..dd4c0668 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/action_card_skeleton.dart @@ -0,0 +1,28 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Skeleton for a single action card with icon, title, and subtitle. +class ActionCardSkeleton extends StatelessWidget { + /// Creates an [ActionCardSkeleton]. + const ActionCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border, width: 0.5), + borderRadius: UiConstants.radiusLg, + ), + child: const Column( + children: [ + UiShimmerBox(width: 36, height: 36), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 60, height: 14), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 100, height: 10), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/actions_section_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/actions_section_skeleton.dart new file mode 100644 index 00000000..4aafa370 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/actions_section_skeleton.dart @@ -0,0 +1,28 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'action_card_skeleton.dart'; + +/// Skeleton for the two side-by-side action cards. +class ActionsSectionSkeleton extends StatelessWidget { + /// Creates an [ActionsSectionSkeleton]. + const ActionsSectionSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerSectionHeader(), + SizedBox(height: UiConstants.space2), + Row( + children: [ + Expanded(child: ActionCardSkeleton()), + SizedBox(width: UiConstants.space4), + Expanded(child: ActionCardSkeleton()), + ], + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/client_home_page_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/client_home_page_skeleton.dart new file mode 100644 index 00000000..09cddb61 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/client_home_page_skeleton.dart @@ -0,0 +1,69 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'actions_section_skeleton.dart'; +import 'coverage_section_skeleton.dart'; +import 'live_activity_section_skeleton.dart'; +import 'reorder_section_skeleton.dart'; +import 'spending_section_skeleton.dart'; + +/// Shimmer loading skeleton for the client home page. +/// +/// Mimics the loaded dashboard layout with action cards, reorder cards, +/// coverage metrics, spending card, and live activity sections. +class ClientHomePageSkeleton extends StatelessWidget { + /// Creates a [ClientHomePageSkeleton]. + const ClientHomePageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: ListView( + children: const [ + // Actions section + Padding( + padding: EdgeInsets.symmetric(horizontal: UiConstants.space4), + child: ActionsSectionSkeleton(), + ), + SizedBox(height: UiConstants.space8), + Divider(color: UiColors.border, height: 0.1), + SizedBox(height: UiConstants.space8), + + // Reorder section + Padding( + padding: EdgeInsets.symmetric(horizontal: UiConstants.space4), + child: ReorderSectionSkeleton(), + ), + SizedBox(height: UiConstants.space8), + Divider(color: UiColors.border, height: 0.1), + SizedBox(height: UiConstants.space8), + + // Coverage section + Padding( + padding: EdgeInsets.symmetric(horizontal: UiConstants.space4), + child: CoverageSectionSkeleton(), + ), + SizedBox(height: UiConstants.space8), + Divider(color: UiColors.border, height: 0.1), + SizedBox(height: UiConstants.space8), + + // Spending section + Padding( + padding: EdgeInsets.symmetric(horizontal: UiConstants.space4), + child: SpendingSectionSkeleton(), + ), + SizedBox(height: UiConstants.space8), + Divider(color: UiColors.border, height: 0.1), + SizedBox(height: UiConstants.space8), + + // Live activity section + Padding( + padding: EdgeInsets.symmetric(horizontal: UiConstants.space4), + child: LiveActivitySectionSkeleton(), + ), + SizedBox(height: UiConstants.space8), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/coverage_section_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/coverage_section_skeleton.dart new file mode 100644 index 00000000..628d6489 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/coverage_section_skeleton.dart @@ -0,0 +1,30 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'metric_card_skeleton.dart'; + +/// Skeleton for the coverage metric cards row. +class CoverageSectionSkeleton extends StatelessWidget { + /// Creates a [CoverageSectionSkeleton]. + const CoverageSectionSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerSectionHeader(), + SizedBox(height: UiConstants.space2), + Row( + children: [ + Expanded(child: MetricCardSkeleton()), + SizedBox(width: UiConstants.space2), + Expanded(child: MetricCardSkeleton()), + SizedBox(width: UiConstants.space2), + Expanded(child: MetricCardSkeleton()), + ], + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/live_activity_section_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/live_activity_section_skeleton.dart new file mode 100644 index 00000000..0abe8950 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/live_activity_section_skeleton.dart @@ -0,0 +1,23 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Skeleton for the live activity section. +class LiveActivitySectionSkeleton extends StatelessWidget { + /// Creates a [LiveActivitySectionSkeleton]. + const LiveActivitySectionSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerSectionHeader(), + SizedBox(height: UiConstants.space2), + UiShimmerStatsCard(), + SizedBox(height: UiConstants.space3), + UiShimmerListItem(), + UiShimmerListItem(), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/metric_card_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/metric_card_skeleton.dart new file mode 100644 index 00000000..bb154f8d --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/metric_card_skeleton.dart @@ -0,0 +1,33 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Skeleton for a single coverage metric card. +class MetricCardSkeleton extends StatelessWidget { + /// Creates a [MetricCardSkeleton]. + const MetricCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space2), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border, width: 0.5), + borderRadius: UiConstants.radiusLg, + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + UiShimmerCircle(size: 14), + SizedBox(width: UiConstants.space1), + UiShimmerLine(width: 40, height: 10), + ], + ), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 32, height: 20), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/reorder_card_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/reorder_card_skeleton.dart new file mode 100644 index 00000000..c5550a68 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/reorder_card_skeleton.dart @@ -0,0 +1,62 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Skeleton for a single reorder card. +class ReorderCardSkeleton extends StatelessWidget { + /// Creates a [ReorderCardSkeleton]. + const ReorderCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border, width: 0.6), + borderRadius: UiConstants.radiusLg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + UiShimmerBox(width: 36, height: 36), + SizedBox(width: UiConstants.space2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 100, height: 14), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 80, height: 10), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + UiShimmerLine(width: 40, height: 14), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 60, height: 10), + ], + ), + ], + ), + const SizedBox(height: UiConstants.space3), + const Row( + children: [ + UiShimmerBox(width: 60, height: 22), + SizedBox(width: UiConstants.space2), + UiShimmerBox(width: 36, height: 22), + ], + ), + const Spacer(), + UiShimmerBox( + width: double.infinity, + height: 32, + borderRadius: UiConstants.radiusLg, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/reorder_section_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/reorder_section_skeleton.dart new file mode 100644 index 00000000..783fc2b0 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/reorder_section_skeleton.dart @@ -0,0 +1,31 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'reorder_card_skeleton.dart'; + +/// Skeleton for the horizontal reorder cards list. +class ReorderSectionSkeleton extends StatelessWidget { + /// Creates a [ReorderSectionSkeleton]. + const ReorderSectionSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + UiShimmerSectionHeader(), + SizedBox(height: UiConstants.space2), + SizedBox( + height: 164, + child: Row( + children: [ + Expanded(child: ReorderCardSkeleton()), + SizedBox(width: UiConstants.space3), + Expanded(child: ReorderCardSkeleton()), + ], + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/spending_card_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/spending_card_skeleton.dart new file mode 100644 index 00000000..dee41bff --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/spending_card_skeleton.dart @@ -0,0 +1,47 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Skeleton mimicking the spending card layout. +class SpendingCardSkeleton extends StatelessWidget { + /// Creates a [SpendingCardSkeleton]. + const SpendingCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + ), + child: const Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 60, height: 10), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 80, height: 22), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 50, height: 10), + ], + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + UiShimmerLine(width: 60, height: 10), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 70, height: 18), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 50, height: 10), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/spending_section_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/spending_section_skeleton.dart new file mode 100644 index 00000000..c46a7e2a --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/spending_section_skeleton.dart @@ -0,0 +1,22 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'spending_card_skeleton.dart'; + +/// Skeleton for the spending gradient card. +class SpendingSectionSkeleton extends StatelessWidget { + /// Creates a [SpendingSectionSkeleton]. + const SpendingSectionSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerSectionHeader(), + SizedBox(height: UiConstants.space2), + SpendingCardSkeleton(), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart index d120664b..28857947 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart @@ -12,6 +12,7 @@ import '../blocs/client_hubs_state.dart'; import '../widgets/hub_card.dart'; import '../widgets/hub_empty_state.dart'; import '../widgets/hub_info_card.dart'; +import '../widgets/hubs_page_skeleton.dart'; /// The main page for the client hubs feature. /// @@ -94,7 +95,7 @@ class ClientHubsPage extends StatelessWidget { ), if (state.status == ClientHubsStatus.loading) - const Center(child: CircularProgressIndicator()) + const HubsPageSkeleton() else if (state.hubs.isEmpty) HubEmptyState( onAddPressed: () async { diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hubs_page_skeleton.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hubs_page_skeleton.dart new file mode 100644 index 00000000..4fcb39bd --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hubs_page_skeleton.dart @@ -0,0 +1,56 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer loading skeleton for the hubs list page. +/// +/// Shows placeholder hub cards matching the [HubCard] layout with a +/// leading icon box, title line, and address line. +class HubsPageSkeleton extends StatelessWidget { + /// Creates a [HubsPageSkeleton]. + const HubsPageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: Column( + children: List.generate(5, (int index) { + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: Container( + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + ), + padding: const EdgeInsets.all(UiConstants.space4), + child: Row( + children: [ + // Leading icon placeholder + UiShimmerBox( + width: 52, + height: 52, + borderRadius: UiConstants.radiusLg, + ), + const SizedBox(width: UiConstants.space4), + // Title and address lines + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 160, height: 16), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 200, height: 12), + ], + ), + ), + const SizedBox(width: UiConstants.space3), + // Chevron placeholder + const UiShimmerBox(width: 16, height: 16), + ], + ), + ), + ); + }), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/analyze.txt b/apps/mobile/packages/features/client/orders/analyze.txt deleted file mode 100644 index 28d6d1d5..00000000 Binary files a/apps/mobile/packages/features/client/orders/analyze.txt and /dev/null differ diff --git a/apps/mobile/packages/features/client/orders/analyze_output.txt b/apps/mobile/packages/features/client/orders/analyze_output.txt deleted file mode 100644 index 4c48dc48..00000000 Binary files a/apps/mobile/packages/features/client/orders/analyze_output.txt and /dev/null differ diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart index b17c6513..8afdfcb2 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart @@ -4,7 +4,9 @@ import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'data/repositories_impl/client_create_order_repository_impl.dart'; +import 'data/repositories_impl/client_order_query_repository_impl.dart'; import 'domain/repositories/client_create_order_repository_interface.dart'; +import 'domain/repositories/client_order_query_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'; @@ -18,6 +20,7 @@ import 'presentation/pages/one_time_order_page.dart'; import 'presentation/pages/permanent_order_page.dart'; import 'presentation/pages/rapid_order_page.dart'; import 'presentation/pages/recurring_order_page.dart'; +import 'presentation/pages/review_order_page.dart'; /// Module for the Client Create Order feature. /// @@ -39,6 +42,12 @@ class ClientCreateOrderModule extends Module { ), ); + i.addLazySingleton( + () => ClientOrderQueryRepositoryImpl( + service: i.get(), + ), + ); + // UseCases i.addLazySingleton(CreateOneTimeOrderUseCase.new); i.addLazySingleton(CreatePermanentOrderUseCase.new); @@ -57,14 +66,20 @@ class ClientCreateOrderModule extends Module { ), ); i.add(OneTimeOrderBloc.new); - i.add(PermanentOrderBloc.new); + i.add( + () => PermanentOrderBloc( + i.get(), + i.get(), + i.get(), + ), + ); i.add(RecurringOrderBloc.new); } @override void routes(RouteManager r) { r.child( - '/', + ClientPaths.childRoute(ClientPaths.createOrder, ClientPaths.createOrder), child: (BuildContext context) => const ClientCreateOrderPage(), ); r.child( @@ -95,5 +110,12 @@ class ClientCreateOrderModule extends Module { ), child: (BuildContext context) => const PermanentOrderPage(), ); + r.child( + ClientPaths.childRoute( + ClientPaths.createOrder, + ClientPaths.createOrderReview, + ), + child: (BuildContext context) => const ReviewOrderPage(), + ); } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_order_query_repository_impl.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_order_query_repository_impl.dart new file mode 100644 index 00000000..723b536e --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_order_query_repository_impl.dart @@ -0,0 +1,107 @@ +import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_domain/krow_domain.dart'; + +import '../../domain/models/order_hub.dart'; +import '../../domain/models/order_manager.dart'; +import '../../domain/models/order_role.dart'; +import '../../domain/repositories/client_order_query_repository_interface.dart'; + +/// Data layer implementation of [ClientOrderQueryRepositoryInterface]. +/// +/// Delegates all backend calls to [dc.DataConnectService] using the +/// `_service.run()` pattern for automatic auth validation, token refresh, +/// and retry logic. Each method maps Data Connect response types to the +/// corresponding clean domain models. +class ClientOrderQueryRepositoryImpl + implements ClientOrderQueryRepositoryInterface { + /// Creates an instance backed by the given [service]. + ClientOrderQueryRepositoryImpl({required dc.DataConnectService service}) + : _service = service; + + final dc.DataConnectService _service; + + @override + Future> getVendors() async { + return _service.run(() async { + final result = await _service.connector.listVendors().execute(); + return result.data.vendors + .map( + (dc.ListVendorsVendors vendor) => Vendor( + id: vendor.id, + name: vendor.companyName, + rates: const {}, + ), + ) + .toList(); + }); + } + + @override + Future> getRolesByVendor(String vendorId) async { + return _service.run(() async { + final result = await _service.connector + .listRolesByVendorId(vendorId: vendorId) + .execute(); + return result.data.roles + .map( + (dc.ListRolesByVendorIdRoles role) => OrderRole( + id: role.id, + name: role.name, + costPerHour: role.costPerHour, + ), + ) + .toList(); + }); + } + + @override + Future> getHubsByOwner(String ownerId) async { + return _service.run(() async { + final result = await _service.connector + .listTeamHubsByOwnerId(ownerId: ownerId) + .execute(); + return result.data.teamHubs + .map( + (dc.ListTeamHubsByOwnerIdTeamHubs hub) => OrderHub( + 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(); + }); + } + + @override + Future> getManagersByHub(String hubId) async { + return _service.run(() async { + final result = await _service.connector.listTeamMembers().execute(); + return result.data.teamMembers + .where( + (dc.ListTeamMembersTeamMembers member) => + member.teamHubId == hubId && + member.role is dc.Known && + (member.role as dc.Known).value == + dc.TeamMemberRole.MANAGER, + ) + .map( + (dc.ListTeamMembersTeamMembers member) => OrderManager( + id: member.id, + name: member.user.fullName ?? 'Unknown', + ), + ) + .toList(); + }); + } + + @override + Future getBusinessId() => _service.getBusinessId(); +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/models/order_hub.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/models/order_hub.dart new file mode 100644 index 00000000..b0526c93 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/models/order_hub.dart @@ -0,0 +1,72 @@ +import 'package:equatable/equatable.dart'; + +/// A team hub (location) available for order assignment. +/// +/// This domain model represents a physical hub location owned by the business. +/// It is used to populate hub selection dropdowns and to attach location +/// details when creating shifts for an order. +class OrderHub extends Equatable { + /// Creates an [OrderHub] with the required [id], [name], and [address], + /// plus optional geo-location and address component fields. + const OrderHub({ + 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, + }); + + /// Unique identifier of the hub. + final String id; + + /// Human-readable display name of the hub. + final String name; + + /// Full street address of the hub. + final String address; + + /// Google Places ID, if available. + final String? placeId; + + /// Geographic latitude of the hub. + final double? latitude; + + /// Geographic longitude of the hub. + final double? longitude; + + /// City where the hub is located. + final String? city; + + /// State or province where the hub is located. + final String? state; + + /// Street name portion of the address. + final String? street; + + /// Country where the hub is located. + final String? country; + + /// Postal / ZIP code of the hub. + final String? zipCode; + + @override + List get props => [ + id, + name, + address, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + ]; +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/models/order_manager.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/models/order_manager.dart new file mode 100644 index 00000000..8097fae1 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/models/order_manager.dart @@ -0,0 +1,20 @@ +import 'package:equatable/equatable.dart'; + +/// A hub manager available for assignment to an order. +/// +/// This domain model represents a team member with a MANAGER role at a +/// specific hub. It is used to populate the manager selection dropdown +/// when creating or editing an order. +class OrderManager extends Equatable { + /// Creates an [OrderManager] with the given [id] and [name]. + const OrderManager({required this.id, required this.name}); + + /// Unique identifier of the manager (team member ID). + final String id; + + /// Full display name of the manager. + final String name; + + @override + List get props => [id, name]; +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/models/order_role.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/models/order_role.dart new file mode 100644 index 00000000..fec66427 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/models/order_role.dart @@ -0,0 +1,28 @@ +import 'package:equatable/equatable.dart'; + +/// A role available for staffing positions within an order. +/// +/// This domain model represents a staffing role fetched from the backend, +/// decoupled from any data layer dependencies. It carries the role identity +/// and its hourly cost so the presentation layer can populate dropdowns +/// and calculate estimates. +class OrderRole extends Equatable { + /// Creates an [OrderRole] with the given [id], [name], and [costPerHour]. + const OrderRole({ + required this.id, + required this.name, + required this.costPerHour, + }); + + /// Unique identifier of the role. + final String id; + + /// Human-readable display name of the role. + final String name; + + /// Hourly cost rate for this role. + final double costPerHour; + + @override + List get props => [id, name, costPerHour]; +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/repositories/client_order_query_repository_interface.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/repositories/client_order_query_repository_interface.dart new file mode 100644 index 00000000..1ab9a2c7 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/repositories/client_order_query_repository_interface.dart @@ -0,0 +1,39 @@ +import 'package:krow_domain/krow_domain.dart'; + +import '../models/order_hub.dart'; +import '../models/order_manager.dart'; +import '../models/order_role.dart'; + +/// Interface for querying order-related reference data. +/// +/// This repository centralises the read-only queries that the order creation +/// BLoCs need (vendors, roles, hubs, managers) so that they no longer depend +/// directly on [DataConnectService] or the `krow_data_connect` package. +/// +/// Implementations live in the data layer and translate backend responses +/// into clean domain models. +abstract interface class ClientOrderQueryRepositoryInterface { + /// Returns the list of available vendors. + /// + /// The returned [Vendor] objects come from the shared `krow_domain` package + /// because `Vendor` is already a clean domain entity. + Future> getVendors(); + + /// Returns the roles offered by the vendor identified by [vendorId]. + Future> getRolesByVendor(String vendorId); + + /// Returns the team hubs owned by the business identified by [ownerId]. + Future> getHubsByOwner(String ownerId); + + /// Returns the managers assigned to the hub identified by [hubId]. + /// + /// Only team members with the MANAGER role at the given hub are included. + Future> getManagersByHub(String hubId); + + /// Returns the current business ID from the active client session. + /// + /// This allows BLoCs to resolve the business ID without depending on + /// the data layer's session store directly, keeping the presentation + /// layer free from `krow_data_connect` imports. + Future getBusinessId(); +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart index 7c3a4435..1f4ceb17 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart @@ -1,10 +1,12 @@ import 'package:client_create_order/src/domain/arguments/one_time_order_arguments.dart'; +import 'package:client_create_order/src/domain/models/order_hub.dart'; +import 'package:client_create_order/src/domain/models/order_manager.dart'; +import 'package:client_create_order/src/domain/models/order_role.dart'; +import 'package:client_create_order/src/domain/repositories/client_order_query_repository_interface.dart'; import 'package:client_create_order/src/domain/usecases/create_one_time_order_usecase.dart'; import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart'; -import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; 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'; import 'one_time_order_event.dart'; @@ -18,7 +20,7 @@ class OneTimeOrderBloc extends Bloc OneTimeOrderBloc( this._createOneTimeOrderUseCase, this._getOrderDetailsForReorderUseCase, - this._service, + this._queryRepository, ) : super(OneTimeOrderState.initial()) { on(_onVendorsLoaded); on(_onVendorChanged); @@ -39,25 +41,11 @@ class OneTimeOrderBloc extends Bloc } final CreateOneTimeOrderUseCase _createOneTimeOrderUseCase; final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase; - final dc.DataConnectService _service; + final ClientOrderQueryRepositoryInterface _queryRepository; Future _loadVendors() async { final List? vendors = await handleErrorWithResult( - action: () async { - final fdc.QueryResult result = await _service - .connector - .listVendors() - .execute(); - return result.data.vendors - .map( - (dc.ListVendorsVendors vendor) => Vendor( - id: vendor.id, - name: vendor.companyName, - rates: const {}, - ), - ) - .toList(); - }, + action: () => _queryRepository.getVendors(), onError: (_) => add(const OneTimeOrderVendorsLoaded([])), ); @@ -72,19 +60,14 @@ class OneTimeOrderBloc extends Bloc ) async { final List? roles = await handleErrorWithResult( action: () async { - final fdc.QueryResult< - dc.ListRolesByVendorIdData, - dc.ListRolesByVendorIdVariables - > - result = await _service.connector - .listRolesByVendorId(vendorId: vendorId) - .execute(); - return result.data.roles + final List result = + await _queryRepository.getRolesByVendor(vendorId); + return result .map( - (dc.ListRolesByVendorIdRoles role) => OneTimeOrderRoleOption( - id: role.id, - name: role.name, - costPerHour: role.costPerHour, + (OrderRole r) => OneTimeOrderRoleOption( + id: r.id, + name: r.name, + costPerHour: r.costPerHour, ), ) .toList(); @@ -101,28 +84,23 @@ class OneTimeOrderBloc extends Bloc Future _loadHubs() async { final List? hubs = await handleErrorWithResult( action: () async { - final String businessId = await _service.getBusinessId(); - final fdc.QueryResult< - dc.ListTeamHubsByOwnerIdData, - dc.ListTeamHubsByOwnerIdVariables - > - result = await _service.connector - .listTeamHubsByOwnerId(ownerId: businessId) - .execute(); - return result.data.teamHubs + final String businessId = await _queryRepository.getBusinessId(); + final List result = + await _queryRepository.getHubsByOwner(businessId); + return result .map( - (dc.ListTeamHubsByOwnerIdTeamHubs hub) => OneTimeOrderHubOption( - 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, + (OrderHub h) => OneTimeOrderHubOption( + id: h.id, + name: h.name, + address: h.address, + placeId: h.placeId, + latitude: h.latitude, + longitude: h.longitude, + city: h.city, + state: h.state, + street: h.street, + country: h.country, + zipCode: h.zipCode, ), ) .toList(); @@ -140,23 +118,14 @@ class OneTimeOrderBloc extends Bloc final List? managers = await handleErrorWithResult( action: () async { - final fdc.QueryResult result = - await _service.connector.listTeamMembers().execute(); - - return result.data.teamMembers - .where( - (dc.ListTeamMembersTeamMembers member) => - member.teamHubId == hubId && - member.role is dc.Known && - (member.role as dc.Known).value == - dc.TeamMemberRole.MANAGER, - ) + final List result = + await _queryRepository.getManagersByHub(hubId); + return result .map( - (dc.ListTeamMembersTeamMembers member) => - OneTimeOrderManagerOption( - id: member.id, - name: member.user.fullName ?? 'Unknown', - ), + (OrderManager m) => OneTimeOrderManagerOption( + id: m.id, + name: m.name, + ), ) .toList(); }, @@ -180,7 +149,11 @@ class OneTimeOrderBloc extends Bloc ? event.vendors.first : null; emit( - state.copyWith(vendors: event.vendors, selectedVendor: selectedVendor), + state.copyWith( + vendors: event.vendors, + selectedVendor: selectedVendor, + isDataLoaded: true, + ), ); if (selectedVendor != null) { await _loadRolesForVendor(selectedVendor.id, emit); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart index c2964f35..b8e3201b 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart @@ -1,6 +1,8 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +import '../../utils/time_parsing_utils.dart'; + enum OneTimeOrderStatus { initial, loading, success, failure } class OneTimeOrderState extends Equatable { @@ -19,6 +21,7 @@ class OneTimeOrderState extends Equatable { this.managers = const [], this.selectedManager, this.isRapidDraft = false, + this.isDataLoaded = false, }); factory OneTimeOrderState.initial() { @@ -50,6 +53,9 @@ class OneTimeOrderState extends Equatable { final OneTimeOrderManagerOption? selectedManager; final bool isRapidDraft; + /// Whether initial data (vendors, hubs) has been fetched from the backend. + final bool isDataLoaded; + OneTimeOrderState copyWith({ DateTime? date, String? location, @@ -65,6 +71,7 @@ class OneTimeOrderState extends Equatable { List? managers, OneTimeOrderManagerOption? selectedManager, bool? isRapidDraft, + bool? isDataLoaded, }) { return OneTimeOrderState( date: date ?? this.date, @@ -81,6 +88,7 @@ class OneTimeOrderState extends Equatable { managers: managers ?? this.managers, selectedManager: selectedManager ?? this.selectedManager, isRapidDraft: isRapidDraft ?? this.isRapidDraft, + isDataLoaded: isDataLoaded ?? this.isDataLoaded, ); } @@ -98,6 +106,77 @@ class OneTimeOrderState extends Equatable { ); } + /// Looks up a role name by its ID, returns `null` if not found. + String? roleNameById(String id) { + for (final OneTimeOrderRoleOption r in roles) { + if (r.id == id) return r.name; + } + return null; + } + + /// Looks up a role cost-per-hour by its ID, returns `0` if not found. + double roleCostById(String id) { + for (final OneTimeOrderRoleOption r in roles) { + if (r.id == id) return r.costPerHour; + } + return 0; + } + + /// Total number of workers across all positions. + int get totalWorkers => positions.fold( + 0, + (int sum, OneTimeOrderPosition p) => sum + p.count, + ); + + /// Sum of (count * costPerHour) across all positions. + double get totalCostPerHour => positions.fold( + 0, + (double sum, OneTimeOrderPosition p) => + sum + (p.count * roleCostById(p.role)), + ); + + /// Estimated total cost: sum of (count * costPerHour * hours) per position. + double get estimatedTotal { + double total = 0; + for (final OneTimeOrderPosition p in positions) { + final double hours = parseHoursFromTimes(p.startTime, p.endTime); + total += p.count * roleCostById(p.role) * hours; + } + return total; + } + + /// Time range string from the first position (e.g. "6:00 AM \u2013 2:00 PM"). + String get shiftTimeRange { + if (positions.isEmpty) return ''; + final OneTimeOrderPosition first = positions.first; + return '${first.startTime} \u2013 ${first.endTime}'; + } + + /// Formatted shift duration from the first position (e.g. "8 hrs (30 min break)"). + String get shiftDuration { + if (positions.isEmpty) return ''; + final OneTimeOrderPosition first = positions.first; + final double hours = parseHoursFromTimes(first.startTime, first.endTime); + if (hours <= 0) return ''; + + final int wholeHours = hours.floor(); + final int minutes = ((hours - wholeHours) * 60).round(); + final StringBuffer buffer = StringBuffer(); + + if (wholeHours > 0) buffer.write('$wholeHours hrs'); + if (minutes > 0) { + if (wholeHours > 0) buffer.write(' '); + buffer.write('$minutes min'); + } + + if (first.lunchBreak != 'NO_BREAK' && + first.lunchBreak.isNotEmpty) { + buffer.write(' (${first.lunchBreak} break)'); + } + + return buffer.toString(); + } + @override List get props => [ date, @@ -114,6 +193,7 @@ class OneTimeOrderState extends Equatable { managers, selectedManager, isRapidDraft, + isDataLoaded, ]; } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart index 5c0c34af..928d248c 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart @@ -1,9 +1,11 @@ +import 'package:client_create_order/src/domain/models/order_hub.dart'; +import 'package:client_create_order/src/domain/models/order_manager.dart'; +import 'package:client_create_order/src/domain/models/order_role.dart'; +import 'package:client_create_order/src/domain/repositories/client_order_query_repository_interface.dart'; import 'package:client_create_order/src/domain/usecases/create_permanent_order_usecase.dart'; import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart'; -import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; 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 'permanent_order_event.dart'; @@ -17,7 +19,7 @@ class PermanentOrderBloc extends Bloc PermanentOrderBloc( this._createPermanentOrderUseCase, this._getOrderDetailsForReorderUseCase, - this._service, + this._queryRepository, ) : super(PermanentOrderState.initial()) { on(_onVendorsLoaded); on(_onVendorChanged); @@ -40,7 +42,7 @@ class PermanentOrderBloc extends Bloc final CreatePermanentOrderUseCase _createPermanentOrderUseCase; final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase; - final dc.DataConnectService _service; + final ClientOrderQueryRepositoryInterface _queryRepository; static const List _dayLabels = [ 'SUN', @@ -54,21 +56,7 @@ class PermanentOrderBloc extends Bloc Future _loadVendors() async { final List? vendors = await handleErrorWithResult( - action: () async { - final fdc.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(); - }, + action: () => _queryRepository.getVendors(), onError: (_) => add(const PermanentOrderVendorsLoaded([])), ); @@ -83,19 +71,14 @@ class PermanentOrderBloc extends Bloc ) async { final List? roles = await handleErrorWithResult( action: () async { - final fdc.QueryResult< - dc.ListRolesByVendorIdData, - dc.ListRolesByVendorIdVariables - > - result = await _service.connector - .listRolesByVendorId(vendorId: vendorId) - .execute(); - return result.data.roles + final List orderRoles = + await _queryRepository.getRolesByVendor(vendorId); + return orderRoles .map( - (dc.ListRolesByVendorIdRoles role) => PermanentOrderRoleOption( - id: role.id, - name: role.name, - costPerHour: role.costPerHour, + (OrderRole r) => PermanentOrderRoleOption( + id: r.id, + name: r.name, + costPerHour: r.costPerHour, ), ) .toList(); @@ -112,19 +95,17 @@ class PermanentOrderBloc extends Bloc Future _loadHubs() async { final List? hubs = await handleErrorWithResult( action: () async { - final String businessId = await _service.getBusinessId(); - final fdc.QueryResult< - dc.ListTeamHubsByOwnerIdData, - dc.ListTeamHubsByOwnerIdVariables - > - result = await _service.connector - .listTeamHubsByOwnerId(ownerId: businessId) - .execute(); - return result.data.teamHubs + final String? businessId = await _queryRepository.getBusinessId(); + if (businessId == null || businessId.isEmpty) { + return []; + } + final List orderHubs = + await _queryRepository.getHubsByOwner(businessId); + return orderHubs .map( - (dc.ListTeamHubsByOwnerIdTeamHubs hub) => PermanentOrderHubOption( + (OrderHub hub) => PermanentOrderHubOption( id: hub.id, - name: hub.hubName, + name: hub.name, address: hub.address, placeId: hub.placeId, latitude: hub.latitude, @@ -155,7 +136,11 @@ class PermanentOrderBloc extends Bloc ? event.vendors.first : null; emit( - state.copyWith(vendors: event.vendors, selectedVendor: selectedVendor), + state.copyWith( + vendors: event.vendors, + selectedVendor: selectedVendor, + isDataLoaded: true, + ), ); if (selectedVendor != null) { await _loadRolesForVendor(selectedVendor.id, emit); @@ -170,10 +155,10 @@ class PermanentOrderBloc extends Bloc await _loadRolesForVendor(event.vendor.id, emit); } - void _onHubsLoaded( + Future _onHubsLoaded( PermanentOrderHubsLoaded event, Emitter emit, - ) { + ) async { final PermanentOrderHubOption? selectedHub = event.hubs.isNotEmpty ? event.hubs.first : null; @@ -186,16 +171,16 @@ class PermanentOrderBloc extends Bloc ); if (selectedHub != null) { - _loadManagersForHub(selectedHub.id, emit); + await _loadManagersForHub(selectedHub.id, emit); } } - void _onHubChanged( + Future _onHubChanged( PermanentOrderHubChanged event, Emitter emit, - ) { + ) async { emit(state.copyWith(selectedHub: event.hub, location: event.hub.name)); - _loadManagersForHub(event.hub.id, emit); + await _loadManagersForHub(event.hub.id, emit); } void _onHubManagerChanged( @@ -219,22 +204,13 @@ class PermanentOrderBloc extends Bloc final List? managers = await handleErrorWithResult( action: () async { - final fdc.QueryResult result = - await _service.connector.listTeamMembers().execute(); - - return result.data.teamMembers - .where( - (dc.ListTeamMembersTeamMembers member) => - member.teamHubId == hubId && - member.role is dc.Known && - (member.role as dc.Known).value == - dc.TeamMemberRole.MANAGER, - ) + final List orderManagers = + await _queryRepository.getManagersByHub(hubId); + return orderManagers .map( - (dc.ListTeamMembersTeamMembers member) => - PermanentOrderManagerOption( - id: member.id, - name: member.user.fullName ?? 'Unknown', + (OrderManager m) => PermanentOrderManagerOption( + id: m.id, + name: m.name, ), ) .toList(); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart index 4cd04e66..0ffea2ff 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +import '../../utils/time_parsing_utils.dart'; enum PermanentOrderStatus { initial, loading, success, failure } @@ -20,6 +21,7 @@ class PermanentOrderState extends Equatable { this.roles = const [], this.managers = const [], this.selectedManager, + this.isDataLoaded = false, }); factory PermanentOrderState.initial() { @@ -67,6 +69,9 @@ class PermanentOrderState extends Equatable { final List managers; final PermanentOrderManagerOption? selectedManager; + /// Whether initial data (vendors, hubs) has been fetched from the backend. + final bool isDataLoaded; + PermanentOrderState copyWith({ DateTime? startDate, List? permanentDays, @@ -83,6 +88,7 @@ class PermanentOrderState extends Equatable { List? roles, List? managers, PermanentOrderManagerOption? selectedManager, + bool? isDataLoaded, }) { return PermanentOrderState( startDate: startDate ?? this.startDate, @@ -100,6 +106,7 @@ class PermanentOrderState extends Equatable { roles: roles ?? this.roles, managers: managers ?? this.managers, selectedManager: selectedManager ?? this.selectedManager, + isDataLoaded: isDataLoaded ?? this.isDataLoaded, ); } @@ -118,6 +125,56 @@ class PermanentOrderState extends Equatable { ); } + /// Looks up a role name by its ID, returns `null` if not found. + String? roleNameById(String id) { + for (final PermanentOrderRoleOption r in roles) { + if (r.id == id) return r.name; + } + return null; + } + + /// Looks up a role cost-per-hour by its ID, returns `0` if not found. + double roleCostById(String id) { + for (final PermanentOrderRoleOption r in roles) { + if (r.id == id) return r.costPerHour; + } + return 0; + } + + /// Total number of workers across all positions. + int get totalWorkers => positions.fold( + 0, + (int sum, PermanentOrderPosition p) => sum + p.count, + ); + + /// Sum of (count * costPerHour) across all positions. + double get totalCostPerHour => positions.fold( + 0, + (double sum, PermanentOrderPosition p) => + sum + (p.count * roleCostById(p.role)), + ); + + /// Daily cost: sum of (count * costPerHour * hours) per position. + double get dailyCost { + double total = 0; + for (final PermanentOrderPosition p in positions) { + final double hours = parseHoursFromTimes(p.startTime, p.endTime); + total += p.count * roleCostById(p.role) * hours; + } + return total; + } + + /// Estimated weekly total cost for the permanent order. + /// + /// Calculated as [dailyCost] multiplied by the number of selected + /// [permanentDays] per week. + double get estimatedTotal => dailyCost * permanentDays.length; + + /// Formatted repeat days (e.g. "Mon, Tue, Wed"). + String get formattedRepeatDays => permanentDays.map( + (String day) => day[0] + day.substring(1).toLowerCase(), + ).join(', '); + @override List get props => [ startDate, @@ -135,6 +192,7 @@ class PermanentOrderState extends Equatable { roles, managers, selectedManager, + isDataLoaded, ]; } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart index 2c51fef9..972db182 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart @@ -1,23 +1,32 @@ +import 'package:client_create_order/src/domain/models/order_hub.dart'; +import 'package:client_create_order/src/domain/models/order_manager.dart'; +import 'package:client_create_order/src/domain/models/order_role.dart'; +import 'package:client_create_order/src/domain/repositories/client_order_query_repository_interface.dart'; import 'package:client_create_order/src/domain/usecases/create_recurring_order_usecase.dart'; import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart'; -import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; 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 'recurring_order_event.dart'; import 'recurring_order_state.dart'; /// BLoC for managing the recurring order creation form. +/// +/// This BLoC delegates all backend queries to +/// [ClientOrderQueryRepositoryInterface] and order submission to +/// [CreateRecurringOrderUseCase], keeping the presentation layer free +/// from direct `krow_data_connect` imports. class RecurringOrderBloc extends Bloc with BlocErrorHandler, SafeBloc { + /// Creates a [RecurringOrderBloc] with the required use cases and + /// query repository. RecurringOrderBloc( this._createRecurringOrderUseCase, this._getOrderDetailsForReorderUseCase, - this._service, + this._queryRepository, ) : super(RecurringOrderState.initial()) { on(_onVendorsLoaded); on(_onVendorChanged); @@ -41,7 +50,7 @@ class RecurringOrderBloc extends Bloc final CreateRecurringOrderUseCase _createRecurringOrderUseCase; final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase; - final dc.DataConnectService _service; + final ClientOrderQueryRepositoryInterface _queryRepository; static const List _dayLabels = [ 'SUN', @@ -53,24 +62,14 @@ class RecurringOrderBloc extends Bloc 'SAT', ]; + /// Loads the list of available vendors from the query repository. Future _loadVendors() async { final List? vendors = await handleErrorWithResult( action: () async { - final fdc.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(); + return _queryRepository.getVendors(); }, - onError: (_) => add(const RecurringOrderVendorsLoaded([])), + onError: (_) => + add(const RecurringOrderVendorsLoaded([])), ); if (vendors != null) { @@ -78,25 +77,22 @@ class RecurringOrderBloc extends Bloc } } + /// Loads roles for the given [vendorId] and maps them to presentation + /// option models. Future _loadRolesForVendor( String vendorId, Emitter emit, ) async { final List? roles = await handleErrorWithResult( action: () async { - final fdc.QueryResult< - dc.ListRolesByVendorIdData, - dc.ListRolesByVendorIdVariables - > - result = await _service.connector - .listRolesByVendorId(vendorId: vendorId) - .execute(); - return result.data.roles + final List orderRoles = + await _queryRepository.getRolesByVendor(vendorId); + return orderRoles .map( - (dc.ListRolesByVendorIdRoles role) => RecurringOrderRoleOption( - id: role.id, - name: role.name, - costPerHour: role.costPerHour, + (OrderRole r) => RecurringOrderRoleOption( + id: r.id, + name: r.name, + costPerHour: r.costPerHour, ), ) .toList(); @@ -110,22 +106,19 @@ class RecurringOrderBloc extends Bloc } } + /// Loads team hubs for the current business owner and maps them to + /// presentation option models. Future _loadHubs() async { final List? hubs = await handleErrorWithResult( action: () async { - final String businessId = await _service.getBusinessId(); - final fdc.QueryResult< - dc.ListTeamHubsByOwnerIdData, - dc.ListTeamHubsByOwnerIdVariables - > - result = await _service.connector - .listTeamHubsByOwnerId(ownerId: businessId) - .execute(); - return result.data.teamHubs + final String businessId = await _queryRepository.getBusinessId(); + final List orderHubs = + await _queryRepository.getHubsByOwner(businessId); + return orderHubs .map( - (dc.ListTeamHubsByOwnerIdTeamHubs hub) => RecurringOrderHubOption( + (OrderHub hub) => RecurringOrderHubOption( id: hub.id, - name: hub.hubName, + name: hub.name, address: hub.address, placeId: hub.placeId, latitude: hub.latitude, @@ -156,7 +149,11 @@ class RecurringOrderBloc extends Bloc ? event.vendors.first : null; emit( - state.copyWith(vendors: event.vendors, selectedVendor: selectedVendor), + state.copyWith( + vendors: event.vendors, + selectedVendor: selectedVendor, + isDataLoaded: true, + ), ); if (selectedVendor != null) { await _loadRolesForVendor(selectedVendor.id, emit); @@ -213,6 +210,8 @@ class RecurringOrderBloc extends Bloc emit(state.copyWith(managers: event.managers)); } + /// Loads managers for the given [hubId] and maps them to presentation + /// option models. Future _loadManagersForHub( String hubId, Emitter emit, @@ -220,22 +219,13 @@ class RecurringOrderBloc extends Bloc final List? managers = await handleErrorWithResult( action: () async { - final fdc.QueryResult result = - await _service.connector.listTeamMembers().execute(); - - return result.data.teamMembers - .where( - (dc.ListTeamMembersTeamMembers member) => - member.teamHubId == hubId && - member.role is dc.Known && - (member.role as dc.Known).value == - dc.TeamMemberRole.MANAGER, - ) + final List orderManagers = + await _queryRepository.getManagersByHub(hubId); + return orderManagers .map( - (dc.ListTeamMembersTeamMembers member) => - RecurringOrderManagerOption( - id: member.id, - name: member.user.fullName ?? 'Unknown', + (OrderManager m) => RecurringOrderManagerOption( + id: m.id, + name: m.name, ), ) .toList(); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart index 8a22eb64..fc9706b7 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +import '../../utils/schedule_utils.dart'; +import '../../utils/time_parsing_utils.dart'; enum RecurringOrderStatus { initial, loading, success, failure } @@ -21,6 +23,7 @@ class RecurringOrderState extends Equatable { this.roles = const [], this.managers = const [], this.selectedManager, + this.isDataLoaded = false, }); factory RecurringOrderState.initial() { @@ -70,6 +73,9 @@ class RecurringOrderState extends Equatable { final List managers; final RecurringOrderManagerOption? selectedManager; + /// Whether initial data (vendors, hubs) has been fetched from the backend. + final bool isDataLoaded; + RecurringOrderState copyWith({ DateTime? startDate, DateTime? endDate, @@ -87,6 +93,7 @@ class RecurringOrderState extends Equatable { List? roles, List? managers, RecurringOrderManagerOption? selectedManager, + bool? isDataLoaded, }) { return RecurringOrderState( startDate: startDate ?? this.startDate, @@ -105,6 +112,7 @@ class RecurringOrderState extends Equatable { roles: roles ?? this.roles, managers: managers ?? this.managers, selectedManager: selectedManager ?? this.selectedManager, + isDataLoaded: isDataLoaded ?? this.isDataLoaded, ); } @@ -125,6 +133,75 @@ class RecurringOrderState extends Equatable { ); } + /// Looks up a role name by its ID, returns `null` if not found. + String? roleNameById(String id) { + for (final RecurringOrderRoleOption r in roles) { + if (r.id == id) return r.name; + } + return null; + } + + /// Looks up a role cost-per-hour by its ID, returns `0` if not found. + double roleCostById(String id) { + for (final RecurringOrderRoleOption r in roles) { + if (r.id == id) return r.costPerHour; + } + return 0; + } + + /// Total number of workers across all positions. + int get totalWorkers => positions.fold( + 0, + (int sum, RecurringOrderPosition p) => sum + p.count, + ); + + /// Sum of (count * costPerHour) across all positions. + double get totalCostPerHour => positions.fold( + 0, + (double sum, RecurringOrderPosition p) => + sum + (p.count * roleCostById(p.role)), + ); + + /// Daily cost: sum of (count * costPerHour * hours) per position. + double get dailyCost { + double total = 0; + for (final RecurringOrderPosition p in positions) { + final double hours = parseHoursFromTimes(p.startTime, p.endTime); + total += p.count * roleCostById(p.role) * hours; + } + return total; + } + + /// Total number of working days between [startDate] and [endDate] + /// (inclusive) that match the selected [recurringDays]. + /// + /// Iterates day-by-day and counts each date whose weekday label + /// (e.g. "MON", "TUE") appears in [recurringDays]. + int get totalWorkingDays { + final Set selectedSet = recurringDays.toSet(); + int count = 0; + for ( + DateTime day = startDate; + !day.isAfter(endDate); + day = day.add(const Duration(days: 1)) + ) { + if (selectedSet.contains(weekdayLabel(day))) { + count++; + } + } + return count; + } + + /// Estimated total cost for the entire recurring order period. + /// + /// Calculated as [dailyCost] multiplied by [totalWorkingDays]. + double get estimatedTotal => dailyCost * totalWorkingDays; + + /// Formatted repeat days (e.g. "Mon, Tue, Wed"). + String get formattedRepeatDays => recurringDays.map( + (String day) => day[0] + day.substring(1).toLowerCase(), + ).join(', '); + @override List get props => [ startDate, @@ -143,6 +220,7 @@ class RecurringOrderState extends Equatable { roles, managers, selectedManager, + isDataLoaded, ]; } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/models/review_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/models/review_order_arguments.dart new file mode 100644 index 00000000..c00a1e78 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/models/review_order_arguments.dart @@ -0,0 +1,52 @@ +import '../widgets/review_order/review_order_positions_card.dart'; + +/// Identifies the order type for rendering the correct schedule layout +/// on the review page. +enum ReviewOrderType { oneTime, recurring, permanent } + +/// Data transfer object passed as route arguments to the [ReviewOrderPage]. +/// +/// Contains pre-formatted display strings for every section of the review +/// summary. The form page is responsible for converting BLoC state into +/// these human-readable values before navigating. +class ReviewOrderArguments { + const ReviewOrderArguments({ + required this.orderType, + required this.orderName, + required this.hubName, + required this.shiftContactName, + required this.positions, + required this.totalWorkers, + required this.totalCostPerHour, + required this.estimatedTotal, + this.scheduleDate, + this.scheduleTime, + this.scheduleDuration, + this.scheduleStartDate, + this.scheduleEndDate, + this.scheduleRepeatDays, + this.totalLabel, + }); + + final ReviewOrderType orderType; + final String orderName; + final String hubName; + final String shiftContactName; + final List positions; + final int totalWorkers; + final double totalCostPerHour; + final double estimatedTotal; + + /// One-time order schedule fields. + final String? scheduleDate; + final String? scheduleTime; + final String? scheduleDuration; + + /// Recurring / permanent order schedule fields. + final String? scheduleStartDate; + final String? scheduleEndDate; + final String? scheduleRepeatDays; + + /// Optional label override for the total banner (e.g. "Estimated Weekly Total"). + final String? totalLabel; +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart index 1c83311f..e77caf39 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart @@ -1,19 +1,27 @@ +import 'package:client_orders_common/client_orders_common.dart'; +import 'package:core_localization/core_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:client_orders_common/client_orders_common.dart'; +import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import '../blocs/one_time_order/one_time_order_bloc.dart'; import '../blocs/one_time_order/one_time_order_event.dart'; import '../blocs/one_time_order/one_time_order_state.dart'; +import '../models/review_order_arguments.dart'; +import '../utils/time_parsing_utils.dart'; +import '../widgets/review_order/review_order_positions_card.dart'; /// Page for creating a one-time staffing order. -/// Users can specify the date, location, and multiple staff positions required. /// -/// This page initializes the [OneTimeOrderBloc] and displays the [OneTimeOrderView] -/// from the common orders package. It follows the KROW Clean Architecture by being -/// a [StatelessWidget] and mapping local BLoC state to generic UI models. +/// ## Submission Flow +/// +/// When the user taps "Create Order", this page does NOT submit directly. +/// Instead it navigates to [ReviewOrderPage] with a snapshot of the current +/// BLoC state formatted as [ReviewOrderArguments]. If the user confirms on +/// the review page (pops with `true`), this page then fires +/// [OneTimeOrderSubmitted] on the BLoC to perform the actual API call. class OneTimeOrderPage extends StatelessWidget { /// Creates a [OneTimeOrderPage]. const OneTimeOrderPage({super.key}); @@ -36,6 +44,7 @@ class OneTimeOrderPage extends StatelessWidget { ); return OneTimeOrderView( + isDataLoaded: state.isDataLoaded, status: _mapStatus(state.status), errorMessage: state.errorMessage, eventName: state.eventName, @@ -53,8 +62,8 @@ class OneTimeOrderPage extends StatelessWidget { : null, hubManagers: state.managers.map(_mapManager).toList(), isValid: state.isValid, - title: state.isRapidDraft ? 'Rapid Order' : null, - subtitle: state.isRapidDraft ? 'Verify the order details' : null, + title: state.isRapidDraft ? t.client_create_order.rapid_draft.title : null, + subtitle: state.isRapidDraft ? t.client_create_order.rapid_draft.subtitle : null, onEventNameChanged: (String val) => bloc.add(OneTimeOrderEventNameChanged(val)), onVendorChanged: (Vendor val) => @@ -90,15 +99,53 @@ class OneTimeOrderPage extends StatelessWidget { }, onPositionRemoved: (int index) => bloc.add(OneTimeOrderPositionRemoved(index)), - onSubmit: () => bloc.add(const OneTimeOrderSubmitted()), + onSubmit: () => _navigateToReview(state, bloc), onDone: () => Modular.to.toOrdersSpecificDate(state.date), - onBack: () => Modular.to.pop(), + onBack: () => Modular.to.popSafe(), ); }, ), ); } + /// Builds [ReviewOrderArguments] from the current BLoC state and navigates + /// to the review page. Submits the order only if the user confirms. + Future _navigateToReview( + OneTimeOrderState state, + OneTimeOrderBloc bloc, + ) async { + final List reviewPositions = state.positions.map( + (OneTimeOrderPosition p) => ReviewPositionItem( + roleName: state.roleNameById(p.role) ?? p.role, + workerCount: p.count, + costPerHour: state.roleCostById(p.role), + hours: parseHoursFromTimes(p.startTime, p.endTime), + startTime: p.startTime, + endTime: p.endTime, + ), + ).toList(); + + final bool? confirmed = await Modular.to.toCreateOrderReview( + arguments: ReviewOrderArguments( + orderType: ReviewOrderType.oneTime, + orderName: state.eventName, + hubName: state.selectedHub?.name ?? '', + shiftContactName: state.selectedManager?.name ?? '', + positions: reviewPositions, + totalWorkers: state.totalWorkers, + totalCostPerHour: state.totalCostPerHour, + estimatedTotal: state.estimatedTotal, + scheduleDate: DateFormat.yMMMEd().format(state.date), + scheduleTime: state.shiftTimeRange, + scheduleDuration: state.shiftDuration, + ), + ); + + if (confirmed == true) { + bloc.add(const OneTimeOrderSubmitted()); + } + } + OrderFormStatus _mapStatus(OneTimeOrderStatus status) { switch (status) { case OneTimeOrderStatus.initial: diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart index 26109e7a..c018bfe9 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart @@ -1,14 +1,27 @@ +import 'package:core_localization/core_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:client_orders_common/client_orders_common.dart'; +import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart' hide PermanentOrderPosition; import '../blocs/permanent_order/permanent_order_bloc.dart'; import '../blocs/permanent_order/permanent_order_event.dart'; import '../blocs/permanent_order/permanent_order_state.dart'; +import '../models/review_order_arguments.dart'; +import '../utils/schedule_utils.dart'; +import '../utils/time_parsing_utils.dart'; +import '../widgets/review_order/review_order_positions_card.dart'; /// Page for creating a permanent staffing order. +/// +/// ## Submission Flow +/// +/// When the user taps "Create Order", this page navigates to +/// [ReviewOrderPage] with a snapshot of the current BLoC state formatted +/// as [ReviewOrderArguments]. If the user confirms (pops with `true`), +/// this page fires [PermanentOrderSubmitted] on the BLoC. class PermanentOrderPage extends StatelessWidget { /// Creates a [PermanentOrderPage]. const PermanentOrderPage({super.key}); @@ -31,6 +44,7 @@ class PermanentOrderPage extends StatelessWidget { ); return PermanentOrderView( + isDataLoaded: state.isDataLoaded, status: _mapStatus(state.status), errorMessage: state.errorMessage, eventName: state.eventName, @@ -89,64 +103,58 @@ class PermanentOrderPage extends StatelessWidget { }, onPositionRemoved: (int index) => bloc.add(PermanentOrderPositionRemoved(index)), - onSubmit: () => bloc.add(const PermanentOrderSubmitted()), + onSubmit: () => _navigateToReview(state, bloc), onDone: () { - final DateTime initialDate = _firstPermanentShiftDate( + final DateTime initialDate = firstScheduledShiftDate( state.startDate, + state.startDate.add(const Duration(days: 29)), state.permanentDays, ); - // Navigate to orders page with the initial date set to the first recurring shift date Modular.to.toOrdersSpecificDate(initialDate); }, - onBack: () => Modular.to.pop(), + onBack: () => Modular.to.popSafe(), ); }, ), ); } - DateTime _firstPermanentShiftDate( - DateTime startDate, - List permanentDays, - ) { - final DateTime start = DateTime( - startDate.year, - startDate.month, - startDate.day, - ); - final DateTime end = start.add(const Duration(days: 29)); - final Set selected = permanentDays.toSet(); - for ( - DateTime day = start; - !day.isAfter(end); - day = day.add(const Duration(days: 1)) - ) { - if (selected.contains(_weekdayLabel(day))) { - return day; - } - } - return start; - } + /// Builds [ReviewOrderArguments] from the current BLoC state and navigates + /// to the review page. Submits the order only if the user confirms. + Future _navigateToReview( + PermanentOrderState state, + PermanentOrderBloc bloc, + ) async { + final List reviewPositions = state.positions.map( + (PermanentOrderPosition p) => ReviewPositionItem( + roleName: state.roleNameById(p.role) ?? p.role, + workerCount: p.count, + costPerHour: state.roleCostById(p.role), + hours: parseHoursFromTimes(p.startTime, p.endTime), + startTime: p.startTime, + endTime: p.endTime, + ), + ).toList(); - 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: - return 'SUN'; - default: - return 'SUN'; + final bool? confirmed = await Modular.to.toCreateOrderReview( + arguments: ReviewOrderArguments( + orderType: ReviewOrderType.permanent, + orderName: state.eventName, + hubName: state.selectedHub?.name ?? '', + shiftContactName: state.selectedManager?.name ?? '', + positions: reviewPositions, + totalWorkers: state.totalWorkers, + totalCostPerHour: state.totalCostPerHour, + estimatedTotal: state.estimatedTotal, + scheduleStartDate: DateFormat.yMMMd().format(state.startDate), + scheduleRepeatDays: state.formattedRepeatDays, + totalLabel: t.client_create_order.review.estimated_weekly_total, + ), + ); + + if (confirmed == true) { + bloc.add(const PermanentOrderSubmitted()); } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart index c65c26a3..0da250ed 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart @@ -2,13 +2,25 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:client_orders_common/client_orders_common.dart'; +import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart' hide RecurringOrderPosition; import '../blocs/recurring_order/recurring_order_bloc.dart'; import '../blocs/recurring_order/recurring_order_event.dart'; import '../blocs/recurring_order/recurring_order_state.dart'; +import '../models/review_order_arguments.dart'; +import '../utils/schedule_utils.dart'; +import '../utils/time_parsing_utils.dart'; +import '../widgets/review_order/review_order_positions_card.dart'; /// Page for creating a recurring staffing order. +/// +/// ## Submission Flow +/// +/// When the user taps "Create Order", this page navigates to +/// [ReviewOrderPage] with a snapshot of the current BLoC state formatted +/// as [ReviewOrderArguments]. If the user confirms (pops with `true`), +/// this page fires [RecurringOrderSubmitted] on the BLoC. class RecurringOrderPage extends StatelessWidget { /// Creates a [RecurringOrderPage]. const RecurringOrderPage({super.key}); @@ -31,6 +43,7 @@ class RecurringOrderPage extends StatelessWidget { ); return RecurringOrderView( + isDataLoaded: state.isDataLoaded, status: _mapStatus(state.status), errorMessage: state.errorMessage, eventName: state.eventName, @@ -92,7 +105,7 @@ class RecurringOrderPage extends StatelessWidget { }, onPositionRemoved: (int index) => bloc.add(RecurringOrderPositionRemoved(index)), - onSubmit: () => bloc.add(const RecurringOrderSubmitted()), + onSubmit: () => _navigateToReview(state, bloc), onDone: () { final DateTime maxEndDate = state.startDate.add( const Duration(days: 29), @@ -101,64 +114,56 @@ class RecurringOrderPage extends StatelessWidget { state.endDate.isAfter(maxEndDate) ? maxEndDate : state.endDate; - final DateTime initialDate = _firstRecurringShiftDate( + final DateTime initialDate = firstScheduledShiftDate( state.startDate, effectiveEndDate, state.recurringDays, ); - // Navigate to orders page with the initial date set to the first recurring shift date Modular.to.toOrdersSpecificDate(initialDate); }, - onBack: () => Modular.to.pop(), + onBack: () => Modular.to.popSafe(), ); }, ), ); } - DateTime _firstRecurringShiftDate( - DateTime startDate, - DateTime endDate, - List recurringDays, - ) { - final DateTime start = DateTime( - startDate.year, - startDate.month, - startDate.day, - ); - final DateTime end = DateTime(endDate.year, endDate.month, endDate.day); - final Set selected = recurringDays.toSet(); - for ( - DateTime day = start; - !day.isAfter(end); - day = day.add(const Duration(days: 1)) - ) { - if (selected.contains(_weekdayLabel(day))) { - return day; - } - } - return start; - } + /// Builds [ReviewOrderArguments] from the current BLoC state and navigates + /// to the review page. Submits the order only if the user confirms. + Future _navigateToReview( + RecurringOrderState state, + RecurringOrderBloc bloc, + ) async { + final List reviewPositions = state.positions.map( + (RecurringOrderPosition p) => ReviewPositionItem( + roleName: state.roleNameById(p.role) ?? p.role, + workerCount: p.count, + costPerHour: state.roleCostById(p.role), + hours: parseHoursFromTimes(p.startTime, p.endTime), + startTime: p.startTime, + endTime: p.endTime, + ), + ).toList(); - 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: - return 'SUN'; - default: - return 'SUN'; + final bool? confirmed = await Modular.to.toCreateOrderReview( + arguments: ReviewOrderArguments( + orderType: ReviewOrderType.recurring, + orderName: state.eventName, + hubName: state.selectedHub?.name ?? '', + shiftContactName: state.selectedManager?.name ?? '', + positions: reviewPositions, + totalWorkers: state.totalWorkers, + totalCostPerHour: state.totalCostPerHour, + estimatedTotal: state.estimatedTotal, + scheduleStartDate: DateFormat.yMMMd().format(state.startDate), + scheduleEndDate: DateFormat.yMMMd().format(state.endDate), + scheduleRepeatDays: state.formattedRepeatDays, + ), + ); + + if (confirmed == true) { + bloc.add(const RecurringOrderSubmitted()); } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/review_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/review_order_page.dart new file mode 100644 index 00000000..c92ef85d --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/review_order_page.dart @@ -0,0 +1,89 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import '../models/review_order_arguments.dart'; +import '../widgets/review_order/one_time_schedule_section.dart'; +import '../widgets/review_order/permanent_schedule_section.dart'; +import '../widgets/review_order/recurring_schedule_section.dart'; +import '../widgets/review_order/review_order_view.dart'; + +/// Review step in the order creation flow. +/// +/// ## Navigation Flow +/// +/// ``` +/// Form Page (one-time / recurring / permanent) +/// -> user taps "Create Order" +/// -> navigates here with [ReviewOrderArguments] +/// -> user reviews summary +/// -> "Post Order" => pops with `true` => form page submits via BLoC +/// -> back / "Edit" => pops without result => form page resumes editing +/// ``` +/// +/// This page is purely presentational. It receives all display data via +/// [ReviewOrderArguments] and does not hold any BLoC. The calling form +/// page owns the BLoC and only fires the submit event after this page +/// confirms. +class ReviewOrderPage extends StatelessWidget { + /// Creates a [ReviewOrderPage]. + const ReviewOrderPage({super.key}); + + @override + Widget build(BuildContext context) { + final Object? rawArgs = Modular.args.data; + if (rawArgs is! ReviewOrderArguments) { + return Scaffold( + body: Center( + child: Text(t.client_create_order.review.invalid_arguments), + ), + ); + } + + final ReviewOrderArguments args = rawArgs; + final bool showEdit = args.orderType != ReviewOrderType.oneTime; + + return ReviewOrderView( + orderName: args.orderName, + hubName: args.hubName, + shiftContactName: args.shiftContactName, + scheduleSection: _buildScheduleSection(args, showEdit), + positions: args.positions, + totalWorkers: args.totalWorkers, + totalCostPerHour: args.totalCostPerHour, + estimatedTotal: args.estimatedTotal, + totalLabel: args.totalLabel, + showEditButtons: showEdit, + onEditBasics: showEdit ? () => Modular.to.popSafe() : null, + onEditSchedule: showEdit ? () => Modular.to.popSafe() : null, + onEditPositions: showEdit ? () => Modular.to.popSafe() : null, + onBack: () => Modular.to.popSafe(), + onSubmit: () => Modular.to.popSafe(true), + ); + } + + /// Builds the schedule section widget matching the order type. + Widget _buildScheduleSection(ReviewOrderArguments args, bool showEdit) { + switch (args.orderType) { + case ReviewOrderType.oneTime: + return OneTimeScheduleSection( + date: args.scheduleDate ?? '', + time: args.scheduleTime ?? '', + duration: args.scheduleDuration ?? '', + ); + case ReviewOrderType.recurring: + return RecurringScheduleSection( + startDate: args.scheduleStartDate ?? '', + endDate: args.scheduleEndDate ?? '', + repeatDays: args.scheduleRepeatDays ?? '', + onEdit: showEdit ? () => Modular.to.popSafe() : null, + ); + case ReviewOrderType.permanent: + return PermanentScheduleSection( + startDate: args.scheduleStartDate ?? '', + repeatDays: args.scheduleRepeatDays ?? '', + onEdit: showEdit ? () => Modular.to.popSafe() : null, + ); + } + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/schedule_utils.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/schedule_utils.dart new file mode 100644 index 00000000..4928816c --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/schedule_utils.dart @@ -0,0 +1,47 @@ +/// Returns the uppercase three-letter weekday label for [date]. +/// +/// Maps `DateTime.weekday` (1=Monday..7=Sunday) to labels like "MON", "TUE". +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: + return 'SUN'; + default: + return 'SUN'; + } +} + +/// Finds the first date within [startDate]..[endDate] whose weekday matches +/// one of the [selectedDays] labels (e.g. "MON", "TUE"). +/// +/// Returns [startDate] if no match is found. +DateTime firstScheduledShiftDate( + DateTime startDate, + DateTime endDate, + List selectedDays, +) { + final DateTime start = DateTime(startDate.year, startDate.month, startDate.day); + final DateTime end = DateTime(endDate.year, endDate.month, endDate.day); + final Set selected = selectedDays.toSet(); + for ( + DateTime day = start; + !day.isAfter(end); + day = day.add(const Duration(days: 1)) + ) { + if (selected.contains(weekdayLabel(day))) { + return day; + } + } + return start; +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/time_parsing_utils.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/time_parsing_utils.dart new file mode 100644 index 00000000..0cf51154 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/time_parsing_utils.dart @@ -0,0 +1,28 @@ +import 'package:intl/intl.dart'; + +/// Parses a time string in common formats ("6:00 PM", "18:00", "6:00PM"). +/// +/// Returns `null` if no format matches. +DateTime? parseTime(String time) { + for (final String format in ['h:mm a', 'HH:mm', 'h:mma']) { + try { + return DateFormat(format).parse(time.trim()); + } catch (_) { + continue; + } + } + return null; +} + +/// Calculates the number of hours between [startTime] and [endTime]. +/// +/// Handles overnight shifts (negative difference wraps to 24h). +/// Returns `0` if either time string cannot be parsed. +double parseHoursFromTimes(String startTime, String endTime) { + final DateTime? start = parseTime(startTime); + final DateTime? end = parseTime(endTime); + if (start == null || end == null) return 0; + Duration diff = end.difference(start); + if (diff.isNegative) diff += const Duration(hours: 24); + return diff.inMinutes / 60; +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/one_time_schedule_section.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/one_time_schedule_section.dart new file mode 100644 index 00000000..b7cef302 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/one_time_schedule_section.dart @@ -0,0 +1,32 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:flutter/material.dart'; +import 'review_order_info_row.dart'; +import 'review_order_section_card.dart'; + +/// Schedule section for one-time orders. +/// +/// Displays: Date, Time (start-end), Duration (with break info). +class OneTimeScheduleSection extends StatelessWidget { + const OneTimeScheduleSection({ + required this.date, + required this.time, + required this.duration, + super.key, + }); + + final String date; + final String time; + final String duration; + + @override + Widget build(BuildContext context) { + return ReviewOrderSectionCard( + title: t.client_create_order.review.schedule, + children: [ + ReviewOrderInfoRow(label: t.client_create_order.review.date, value: date), + ReviewOrderInfoRow(label: t.client_create_order.review.time, value: time), + ReviewOrderInfoRow(label: t.client_create_order.review.duration, value: duration), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/permanent_schedule_section.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/permanent_schedule_section.dart new file mode 100644 index 00000000..3fe2bff6 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/permanent_schedule_section.dart @@ -0,0 +1,32 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:flutter/material.dart'; +import 'review_order_info_row.dart'; +import 'review_order_section_card.dart'; + +/// Schedule section for permanent orders. +/// +/// Displays: Start Date, Repeat days (no end date). +class PermanentScheduleSection extends StatelessWidget { + const PermanentScheduleSection({ + required this.startDate, + required this.repeatDays, + this.onEdit, + super.key, + }); + + final String startDate; + final String repeatDays; + final VoidCallback? onEdit; + + @override + Widget build(BuildContext context) { + return ReviewOrderSectionCard( + title: t.client_create_order.review.schedule, + onEdit: onEdit, + children: [ + ReviewOrderInfoRow(label: t.client_create_order.review.start_date, value: startDate), + ReviewOrderInfoRow(label: t.client_create_order.review.repeat, value: repeatDays), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/recurring_schedule_section.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/recurring_schedule_section.dart new file mode 100644 index 00000000..5e9ac4d8 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/recurring_schedule_section.dart @@ -0,0 +1,35 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:flutter/material.dart'; +import 'review_order_info_row.dart'; +import 'review_order_section_card.dart'; + +/// Schedule section for recurring orders. +/// +/// Displays: Start Date, End Date, Repeat days. +class RecurringScheduleSection extends StatelessWidget { + const RecurringScheduleSection({ + required this.startDate, + required this.endDate, + required this.repeatDays, + this.onEdit, + super.key, + }); + + final String startDate; + final String endDate; + final String repeatDays; + final VoidCallback? onEdit; + + @override + Widget build(BuildContext context) { + return ReviewOrderSectionCard( + title: t.client_create_order.review.schedule, + onEdit: onEdit, + children: [ + ReviewOrderInfoRow(label: t.client_create_order.review.start_date, value: startDate), + ReviewOrderInfoRow(label: t.client_create_order.review.end_date, value: endDate), + ReviewOrderInfoRow(label: t.client_create_order.review.repeat, value: repeatDays), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_action_bar.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_action_bar.dart new file mode 100644 index 00000000..0f000a61 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_action_bar.dart @@ -0,0 +1,56 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Bottom action bar with a back button and primary submit button. +/// +/// The back button is a compact outlined button with a chevron icon. +/// The submit button fills the remaining space. +class ReviewOrderActionBar extends StatelessWidget { + const ReviewOrderActionBar({ + required this.onBack, + required this.onSubmit, + this.submitLabel, + this.isLoading = false, + super.key, + }); + + final VoidCallback onBack; + final VoidCallback? onSubmit; + final String? submitLabel; + final bool isLoading; + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.only( + left: UiConstants.space6, + right: UiConstants.space6, + top: UiConstants.space3, + bottom: UiConstants.space10, + ), + child: Row( + children: [ + UiButton.secondary( + leadingIcon: UiIcons.chevronLeft, + onPressed: onBack, + size: UiButtonSize.large, + text: '', + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: UiButton.primary( + text: submitLabel ?? t.client_create_order.review.post_order, + onPressed: onSubmit, + isLoading: isLoading, + size: UiButtonSize.large, + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_basics_card.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_basics_card.dart new file mode 100644 index 00000000..d17305ff --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_basics_card.dart @@ -0,0 +1,34 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:flutter/material.dart'; +import 'review_order_info_row.dart'; +import 'review_order_section_card.dart'; + +/// Displays the "Basics" section card showing order name, hub, and +/// shift contact information. +class ReviewOrderBasicsCard extends StatelessWidget { + const ReviewOrderBasicsCard({ + required this.orderName, + required this.hubName, + required this.shiftContactName, + this.onEdit, + super.key, + }); + + final String orderName; + final String hubName; + final String shiftContactName; + final VoidCallback? onEdit; + + @override + Widget build(BuildContext context) { + return ReviewOrderSectionCard( + title: t.client_create_order.review.basics, + onEdit: onEdit, + children: [ + ReviewOrderInfoRow(label: t.client_create_order.review.order_name, value: orderName), + ReviewOrderInfoRow(label: t.client_create_order.review.hub, value: hubName), + ReviewOrderInfoRow(label: t.client_create_order.review.shift_contact, value: shiftContactName), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_info_row.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_info_row.dart new file mode 100644 index 00000000..3946c1a8 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_info_row.dart @@ -0,0 +1,40 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A single key-value row used inside review section cards. +/// +/// Displays a label on the left and a value on the right in a +/// space-between layout. +class ReviewOrderInfoRow extends StatelessWidget { + const ReviewOrderInfoRow({ + required this.label, + required this.value, + super.key, + }); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: UiConstants.space2, + children: [ + Flexible( + child: Text( + label, + style: UiTypography.body2r.textSecondary, + ), + ), + Flexible( + child: Text( + value, + style: UiTypography.body2m, + textAlign: TextAlign.end, + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_positions_card.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_positions_card.dart new file mode 100644 index 00000000..73b0b09d --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_positions_card.dart @@ -0,0 +1,171 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Displays a summary of all positions with a divider and total row. +/// +/// Each position is rendered as a two-line layout: +/// - Line 1: role name (left) and worker count with cost/hr (right). +/// - Line 2: time range and shift hours (right-aligned, muted style). +/// +/// A divider separates the individual positions from the total. +class ReviewOrderPositionsCard extends StatelessWidget { + /// Creates a [ReviewOrderPositionsCard]. + const ReviewOrderPositionsCard({ + required this.positions, + required this.totalWorkers, + required this.totalCostPerHour, + this.onEdit, + super.key, + }); + + /// The list of position items to display. + final List positions; + + /// The total number of workers across all positions. + final int totalWorkers; + + /// The combined cost per hour across all positions. + final double totalCostPerHour; + + /// Optional callback invoked when the user taps "Edit". + final VoidCallback? onEdit; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusXl, + border: Border.all(color: UiColors.border, width: 0.5), + ), + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + t.client_create_order.review.positions, + style: UiTypography.titleUppercase4b.textSecondary, + ), + if (onEdit != null) + GestureDetector( + onTap: onEdit, + child: Text( + t.client_create_order.review.edit, + style: UiTypography.body3m.primary, + ), + ), + ], + ), + ...positions.map(_buildPositionItem), + Padding( + padding: const EdgeInsets.only(top: UiConstants.space3), + child: Container( + height: 1, + color: UiColors.bgSecondary, + ), + ), + Padding( + padding: const EdgeInsets.only(top: UiConstants.space3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + t.client_create_order.review.total, + style: UiTypography.body2m, + ), + Text( + '$totalWorkers workers \u00B7 ' + '\$${totalCostPerHour.toStringAsFixed(0)}/hr', + style: UiTypography.body2b.primary, + ), + ], + ), + ), + ], + ), + ); + } + + /// Builds a two-line widget for a single position. + /// + /// Line 1 shows the role name on the left and worker count with cost on + /// the right. Line 2 shows the time range and shift hours, right-aligned + /// in a secondary/muted style. + Widget _buildPositionItem(ReviewPositionItem position) { + final String formattedHours = position.hours % 1 == 0 + ? position.hours.toInt().toString() + : position.hours.toStringAsFixed(1); + + return Padding( + padding: const EdgeInsets.only(top: UiConstants.space3), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + position.roleName, + style: UiTypography.body2m.textSecondary, + ), + ), + Text( + '${position.workerCount} workers \u00B7 ' + '\$${position.costPerHour.toStringAsFixed(0)}/hr', + style: UiTypography.body2m, + ), + ], + ), + const SizedBox(height: UiConstants.space1), + Align( + alignment: Alignment.centerRight, + child: Text( + '${position.startTime} - ${position.endTime} \u00B7 ' + '$formattedHours ' + '${t.client_create_order.review.hours_suffix}', + style: UiTypography.body3r.textTertiary, + ), + ), + ], + ), + ); + } +} + +/// A single position item for the positions card. +/// +/// Contains the role name, worker count, shift hours, hourly cost, +/// and the start/end times for one position in the review summary. +class ReviewPositionItem { + /// Creates a [ReviewPositionItem]. + const ReviewPositionItem({ + required this.roleName, + required this.workerCount, + required this.costPerHour, + required this.hours, + required this.startTime, + required this.endTime, + }); + + /// The display name of the role for this position. + final String roleName; + + /// The number of workers requested for this position. + final int workerCount; + + /// The cost per hour for this role. + final double costPerHour; + + /// The number of shift hours (derived from start/end time). + final double hours; + + /// The formatted start time of the shift (e.g. "08:00 AM"). + final String startTime; + + /// The formatted end time of the shift (e.g. "04:00 PM"). + final String endTime; +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_section_card.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_section_card.dart new file mode 100644 index 00000000..2b926c53 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_section_card.dart @@ -0,0 +1,57 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A card that groups related review information with a section header. +/// +/// Displays an uppercase section title with an optional "Edit" action +/// and a list of child rows. +class ReviewOrderSectionCard extends StatelessWidget { + const ReviewOrderSectionCard({ + required this.title, + required this.children, + this.onEdit, + super.key, + }); + + final String title; + final List children; + final VoidCallback? onEdit; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusXl, + border: Border.all(color: UiColors.border, width: 0.5), + ), + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title.toUpperCase(), + style: UiTypography.titleUppercase4b.textSecondary, + ), + if (onEdit != null) + GestureDetector( + onTap: onEdit, + child: Text(t.client_create_order.review.edit, style: UiTypography.body3m.primary), + ), + ], + ), + ...children.map( + (Widget child) => Padding( + padding: const EdgeInsets.only(top: UiConstants.space3), + child: child, + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_total_banner.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_total_banner.dart new file mode 100644 index 00000000..82430fd9 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_total_banner.dart @@ -0,0 +1,48 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A highlighted banner displaying the estimated total cost. +/// +/// Uses the primary inverse background color with a bold price display. +/// An optional [label] can override the default "Estimated Total" text. +class ReviewOrderTotalBanner extends StatelessWidget { + const ReviewOrderTotalBanner({ + required this.totalAmount, + this.label, + super.key, + }); + + /// The total monetary amount to display. + final double totalAmount; + + /// Optional label override. Defaults to the localized "Estimated Total". + final String? label; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space4, + ), + decoration: BoxDecoration( + color: UiColors.primaryInverse, + borderRadius: UiConstants.radiusLg, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label ?? t.client_create_order.review.estimated_total, + style: UiTypography.body2m, + ), + Text( + '\$${totalAmount.toStringAsFixed(2)}', + style: UiTypography.headline3b.primary, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_view.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_view.dart new file mode 100644 index 00000000..6e8ef48c --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_view.dart @@ -0,0 +1,122 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'review_order_action_bar.dart'; +import 'review_order_basics_card.dart'; +import 'review_order_positions_card.dart'; +import 'review_order_total_banner.dart'; + +/// The main review order view that displays a summary of the order +/// before submission. +/// +/// This is a "dumb" widget that receives all data via constructor parameters +/// and exposes callbacks for user interactions. It does NOT interact with +/// any BLoC directly. +/// +/// The [scheduleSection] widget is injected to allow different schedule +/// layouts per order type (one-time, recurring, permanent). +class ReviewOrderView extends StatelessWidget { + const ReviewOrderView({ + required this.orderName, + required this.hubName, + required this.shiftContactName, + required this.scheduleSection, + required this.positions, + required this.totalWorkers, + required this.totalCostPerHour, + required this.estimatedTotal, + required this.onBack, + required this.onSubmit, + this.showEditButtons = false, + this.onEditBasics, + this.onEditSchedule, + this.onEditPositions, + this.submitLabel, + this.totalLabel, + this.isLoading = false, + super.key, + }); + + final String orderName; + final String hubName; + final String shiftContactName; + final Widget scheduleSection; + final List positions; + final int totalWorkers; + final double totalCostPerHour; + final double estimatedTotal; + final VoidCallback onBack; + final VoidCallback? onSubmit; + final bool showEditButtons; + final VoidCallback? onEditBasics; + final VoidCallback? onEditSchedule; + final VoidCallback? onEditPositions; + final String? submitLabel; + + /// Optional label override for the total banner. When `null`, the default + /// localized "Estimated Total" text is used. + final String? totalLabel; + final bool isLoading; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: UiAppBar( + showBackButton: true, + onLeadingPressed: onBack, + title: t.client_create_order.review.title, + subtitle: t.client_create_order.review.subtitle, + ), + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space6, + ), + child: Column( + children: [ + const SizedBox(height: UiConstants.space4), + ReviewOrderBasicsCard( + orderName: orderName, + hubName: hubName, + shiftContactName: shiftContactName, + onEdit: showEditButtons ? onEditBasics : null, + ), + const SizedBox(height: UiConstants.space3), + scheduleSection, + const SizedBox(height: UiConstants.space3), + ReviewOrderPositionsCard( + positions: positions, + totalWorkers: totalWorkers, + totalCostPerHour: totalCostPerHour, + onEdit: showEditButtons ? onEditPositions : null, + ), + const SizedBox(height: UiConstants.space3), + ReviewOrderTotalBanner( + totalAmount: estimatedTotal, + label: totalLabel, + ), + const SizedBox(height: UiConstants.space4), + ], + ), + ), + ], + ), + ), + ), + ReviewOrderActionBar( + onBack: onBack, + onSubmit: onSubmit, + submitLabel: submitLabel ?? t.client_create_order.review.post_order, + isLoading: isLoading, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/client_orders_common.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/client_orders_common.dart index 410be326..28fe45ee 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/client_orders_common.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/client_orders_common.dart @@ -1,10 +1,14 @@ // UI Models export 'src/presentation/widgets/order_ui_models.dart'; +// Shared Widgets +export 'src/presentation/widgets/order_bottom_action_button.dart'; +export 'src/presentation/widgets/order_form_skeleton.dart'; + // One Time Order Widgets export 'src/presentation/widgets/one_time_order/one_time_order_date_picker.dart'; export 'src/presentation/widgets/one_time_order/one_time_order_event_name_input.dart'; -export 'src/presentation/widgets/one_time_order/one_time_order_header.dart'; +export 'src/presentation/widgets/one_time_order/one_time_order_form.dart'; export 'src/presentation/widgets/one_time_order/one_time_order_location_input.dart'; export 'src/presentation/widgets/one_time_order/one_time_order_position_card.dart'; export 'src/presentation/widgets/one_time_order/one_time_order_section_header.dart'; @@ -13,8 +17,9 @@ export 'src/presentation/widgets/one_time_order/one_time_order_view.dart'; // Permanent Order Widgets export 'src/presentation/widgets/permanent_order/permanent_order_date_picker.dart'; +export 'src/presentation/widgets/permanent_order/permanent_order_days_selector.dart'; export 'src/presentation/widgets/permanent_order/permanent_order_event_name_input.dart'; -export 'src/presentation/widgets/permanent_order/permanent_order_header.dart'; +export 'src/presentation/widgets/permanent_order/permanent_order_form.dart'; export 'src/presentation/widgets/permanent_order/permanent_order_position_card.dart'; export 'src/presentation/widgets/permanent_order/permanent_order_section_header.dart'; export 'src/presentation/widgets/permanent_order/permanent_order_success_view.dart'; @@ -22,8 +27,9 @@ export 'src/presentation/widgets/permanent_order/permanent_order_view.dart'; // Recurring Order Widgets export 'src/presentation/widgets/recurring_order/recurring_order_date_picker.dart'; +export 'src/presentation/widgets/recurring_order/recurring_order_days_selector.dart'; export 'src/presentation/widgets/recurring_order/recurring_order_event_name_input.dart'; -export 'src/presentation/widgets/recurring_order/recurring_order_header.dart'; +export 'src/presentation/widgets/recurring_order/recurring_order_form.dart'; export 'src/presentation/widgets/recurring_order/recurring_order_position_card.dart'; export 'src/presentation/widgets/recurring_order/recurring_order_section_header.dart'; export 'src/presentation/widgets/recurring_order/recurring_order_success_view.dart'; diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/hub_manager_selector.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/hub_manager_selector.dart index 185b9bef..4dfd6b0f 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/hub_manager_selector.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/hub_manager_selector.dart @@ -32,13 +32,12 @@ class HubManagerSelector extends StatelessWidget { children: [ Text( label, - style: UiTypography.body1m.textPrimary, + style: UiTypography.body1r, ), if (description != null) ...[ - const SizedBox(height: UiConstants.space2), Text(description!, style: UiTypography.body2r.textSecondary), ], - const SizedBox(height: UiConstants.space2), + const SizedBox(height: UiConstants.space3), InkWell( onTap: () => _showSelector(context), borderRadius: BorderRadius.circular(UiConstants.radiusBase), diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_form.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_form.dart new file mode 100644 index 00000000..a21092a0 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_form.dart @@ -0,0 +1,242 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../hub_manager_selector.dart'; +import '../order_ui_models.dart'; +import 'one_time_order_date_picker.dart'; +import 'one_time_order_event_name_input.dart'; +import 'one_time_order_position_card.dart'; +import 'one_time_order_section_header.dart'; + +/// The scrollable form body for the one-time order creation flow. +/// +/// Displays fields for event name, vendor selection, date, hub, hub manager, +/// and a dynamic list of position cards. +class OneTimeOrderForm extends StatelessWidget { + /// Creates a [OneTimeOrderForm]. + const OneTimeOrderForm({ + required this.eventName, + required this.selectedVendor, + required this.vendors, + required this.date, + required this.selectedHub, + required this.hubs, + required this.selectedHubManager, + required this.hubManagers, + required this.positions, + required this.roles, + required this.onEventNameChanged, + required this.onVendorChanged, + required this.onDateChanged, + required this.onHubChanged, + required this.onHubManagerChanged, + required this.onPositionAdded, + required this.onPositionUpdated, + required this.onPositionRemoved, + super.key, + }); + + /// The current event name value. + final String eventName; + + /// The currently selected vendor, if any. + final Vendor? selectedVendor; + + /// The list of available vendors to choose from. + final List vendors; + + /// The selected date for the one-time order. + final DateTime date; + + /// The currently selected hub, if any. + final OrderHubUiModel? selectedHub; + + /// The list of available hubs to choose from. + final List hubs; + + /// The currently selected hub manager, if any. + final OrderManagerUiModel? selectedHubManager; + + /// The list of available hub managers for the selected hub. + final List hubManagers; + + /// The list of position entries in the order. + final List positions; + + /// The list of available roles for position assignment. + final List roles; + + /// Called when the event name text changes. + final ValueChanged onEventNameChanged; + + /// Called when a vendor is selected. + final ValueChanged onVendorChanged; + + /// Called when the date is changed. + final ValueChanged onDateChanged; + + /// Called when a hub is selected. + final ValueChanged onHubChanged; + + /// Called when a hub manager is selected or cleared. + final ValueChanged onHubManagerChanged; + + /// Called when the user requests adding a new position. + final VoidCallback onPositionAdded; + + /// Called when a position at [index] is updated with new values. + final void Function(int index, OrderPositionUiModel position) + onPositionUpdated; + + /// Called when a position at [index] is removed. + final void Function(int index) onPositionRemoved; + + @override + Widget build(BuildContext context) { + final TranslationsClientCreateOrderOneTimeEn labels = + t.client_create_order.one_time; + + return ListView( + padding: const EdgeInsets.all(UiConstants.space5), + children: [ + OneTimeOrderEventNameInput( + label: 'ORDER NAME', + value: eventName, + onChanged: onEventNameChanged, + ), + 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: selectedVendor, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (Vendor? vendor) { + if (vendor != null) { + onVendorChanged(vendor); + } + }, + items: vendors.map((Vendor vendor) { + return DropdownMenuItem( + value: vendor, + child: Text( + vendor.name, + style: UiTypography.body2m.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space4), + + OneTimeOrderDatePicker( + label: labels.date_label, + value: date, + onChanged: onDateChanged, + ), + 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: selectedHub, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (OrderHubUiModel? hub) { + if (hub != null) { + onHubChanged(hub); + } + }, + items: hubs.map((OrderHubUiModel hub) { + return DropdownMenuItem( + value: hub, + child: Text(hub.name, style: UiTypography.body2m.textPrimary), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space4), + + HubManagerSelector( + label: labels.hub_manager_label, + description: labels.hub_manager_desc, + hintText: labels.hub_manager_hint, + noManagersText: labels.hub_manager_empty, + noneText: labels.hub_manager_none, + managers: hubManagers, + selectedManager: selectedHubManager, + onChanged: onHubManagerChanged, + ), + const SizedBox(height: UiConstants.space6), + + OneTimeOrderSectionHeader( + title: labels.positions_title, + actionLabel: labels.add_position, + onAction: onPositionAdded, + ), + const SizedBox(height: UiConstants.space3), + + // Positions List + ...positions.asMap().entries.map(( + MapEntry entry, + ) { + final int index = entry.key; + final OrderPositionUiModel position = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: OneTimeOrderPositionCard( + index: index, + position: position, + isRemovable: positions.length > 1, + positionLabel: labels.positions_title, + roleLabel: labels.select_role, + workersLabel: labels.workers_label, + startLabel: labels.start_label, + endLabel: labels.end_label, + lunchLabel: labels.lunch_break_label, + roles: roles, + onUpdated: (OrderPositionUiModel updated) { + onPositionUpdated(index, updated); + }, + onRemoved: () { + onPositionRemoved(index); + }, + ), + ); + }), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_header.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_header.dart deleted file mode 100644 index d39f6c8b..00000000 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_header.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; - -/// A header widget for the one-time order flow with a colored background. -class OneTimeOrderHeader extends StatelessWidget { - /// Creates a [OneTimeOrderHeader]. - const OneTimeOrderHeader({ - 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/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart index 97d0bb68..3e66e2fa 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart @@ -2,13 +2,11 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart'; + +import '../order_bottom_action_button.dart'; +import '../order_form_skeleton.dart'; import '../order_ui_models.dart'; -import '../hub_manager_selector.dart'; -import 'one_time_order_date_picker.dart'; -import 'one_time_order_event_name_input.dart'; -import 'one_time_order_header.dart'; -import 'one_time_order_position_card.dart'; -import 'one_time_order_section_header.dart'; +import 'one_time_order_form.dart'; import 'one_time_order_success_view.dart'; /// The main content of the One-Time Order page as a dumb widget. @@ -40,6 +38,7 @@ class OneTimeOrderView extends StatelessWidget { required this.onBack, this.title, this.subtitle, + this.isDataLoaded = true, super.key, }); @@ -59,6 +58,9 @@ class OneTimeOrderView extends StatelessWidget { final String? title; final String? subtitle; + /// Whether initial data (vendors, hubs) has been fetched from the backend. + final bool isDataLoaded; + final ValueChanged onEventNameChanged; final ValueChanged onVendorChanged; final ValueChanged onDateChanged; @@ -84,7 +86,12 @@ class OneTimeOrderView extends StatelessWidget { context, message: translateErrorKey(errorMessage!), type: UiSnackbarType.error, - margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16), + // bottom: 140 clears the bottom navigation bar area + margin: const EdgeInsets.only( + bottom: 140, + left: UiConstants.space4, + right: UiConstants.space4, + ), ); }); } @@ -98,322 +105,96 @@ class OneTimeOrderView extends StatelessWidget { ); } + return Scaffold( + appBar: UiAppBar( + showBackButton: true, + onLeadingPressed: onBack, + title: title ?? labels.title, + subtitle: subtitle ?? labels.subtitle, + ), + body: _buildBody(context, labels), + ); + } + + /// Builds the main body of the One-Time Order page, showing either the form or a loading indicator. + Widget _buildBody( + BuildContext context, + TranslationsClientCreateOrderOneTimeEn labels, + ) { + if (!isDataLoaded) { + return const OrderFormSkeleton(); + } + if (vendors.isEmpty && status != OrderFormStatus.loading) { - return Scaffold( - body: Column( - children: [ - OneTimeOrderHeader( - title: title ?? labels.title, - subtitle: subtitle ?? labels.subtitle, - onBack: onBack, - ), - 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 Column( + children: [ + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + UiIcons.search, + size: 64, + color: UiColors.iconInactive, + ), + const SizedBox(height: UiConstants.space4), + Text( + t.client_create_order.no_vendors_title, + style: UiTypography.headline3m.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + Text( + t.client_create_order.no_vendors_description, + style: UiTypography.body2r.textSecondary, + textAlign: TextAlign.center, + ), + ], ), ), - ], - ), + ), + ], ); } - return Scaffold( - body: Column( - children: [ - OneTimeOrderHeader( - title: title ?? labels.title, - subtitle: subtitle ?? labels.subtitle, - onBack: onBack, - ), - Expanded( - child: Stack( - children: [ - _OneTimeOrderForm( - eventName: eventName, - selectedVendor: selectedVendor, - vendors: vendors, - date: date, - selectedHub: selectedHub, - hubs: hubs, - selectedHubManager: selectedHubManager, - hubManagers: hubManagers, - positions: positions, - roles: roles, - onEventNameChanged: onEventNameChanged, - onVendorChanged: onVendorChanged, - onDateChanged: onDateChanged, - onHubChanged: onHubChanged, - onHubManagerChanged: onHubManagerChanged, - onPositionAdded: onPositionAdded, - onPositionUpdated: onPositionUpdated, - onPositionRemoved: onPositionRemoved, - ), - if (status == OrderFormStatus.loading) - const Center(child: CircularProgressIndicator()), - ], - ), - ), - _BottomActionButton( - label: status == OrderFormStatus.loading - ? labels.creating - : labels.create_order, - isLoading: status == OrderFormStatus.loading, - onPressed: isValid ? onSubmit : null, - ), - ], - ), - ); - } -} - -class _OneTimeOrderForm extends StatelessWidget { - const _OneTimeOrderForm({ - required this.eventName, - required this.selectedVendor, - required this.vendors, - required this.date, - required this.selectedHub, - required this.hubs, - required this.selectedHubManager, - required this.hubManagers, - required this.positions, - required this.roles, - required this.onEventNameChanged, - required this.onVendorChanged, - required this.onDateChanged, - required this.onHubChanged, - required this.onHubManagerChanged, - required this.onPositionAdded, - required this.onPositionUpdated, - required this.onPositionRemoved, - }); - - final String eventName; - final Vendor? selectedVendor; - final List vendors; - final DateTime date; - final OrderHubUiModel? selectedHub; - final List hubs; - final OrderManagerUiModel? selectedHubManager; - final List hubManagers; - final List positions; - final List roles; - - final ValueChanged onEventNameChanged; - final ValueChanged onVendorChanged; - final ValueChanged onDateChanged; - final ValueChanged onHubChanged; - final ValueChanged onHubManagerChanged; - final VoidCallback onPositionAdded; - final void Function(int index, OrderPositionUiModel position) - onPositionUpdated; - final void Function(int index) onPositionRemoved; - - @override - Widget build(BuildContext context) { - final TranslationsClientCreateOrderOneTimeEn labels = - t.client_create_order.one_time; - - return ListView( - padding: const EdgeInsets.all(UiConstants.space5), + return Column( children: [ - Text( - labels.create_your_order, - style: UiTypography.headline3m.textPrimary, - ), - const SizedBox(height: UiConstants.space4), - - OneTimeOrderEventNameInput( - label: 'ORDER NAME', - value: eventName, - onChanged: onEventNameChanged, - ), - 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: selectedVendor, - icon: const Icon( - UiIcons.chevronDown, - size: 18, - color: UiColors.iconSecondary, + Expanded( + child: Stack( + children: [ + OneTimeOrderForm( + eventName: eventName, + selectedVendor: selectedVendor, + vendors: vendors, + date: date, + selectedHub: selectedHub, + hubs: hubs, + selectedHubManager: selectedHubManager, + hubManagers: hubManagers, + positions: positions, + roles: roles, + onEventNameChanged: onEventNameChanged, + onVendorChanged: onVendorChanged, + onDateChanged: onDateChanged, + onHubChanged: onHubChanged, + onHubManagerChanged: onHubManagerChanged, + onPositionAdded: onPositionAdded, + onPositionUpdated: onPositionUpdated, + onPositionRemoved: onPositionRemoved, ), - onChanged: (Vendor? vendor) { - if (vendor != null) { - onVendorChanged(vendor); - } - }, - items: vendors.map((Vendor vendor) { - return DropdownMenuItem( - value: vendor, - child: Text( - vendor.name, - style: UiTypography.body2m.textPrimary, - ), - ); - }).toList(), - ), + if (status == OrderFormStatus.loading) + const Center(child: CircularProgressIndicator()), + ], ), ), - const SizedBox(height: UiConstants.space4), - - OneTimeOrderDatePicker( - label: labels.date_label, - value: date, - onChanged: onDateChanged, + OrderBottomActionButton( + label: status == OrderFormStatus.loading + ? labels.creating + : labels.create_order, + isLoading: status == OrderFormStatus.loading, + onPressed: isValid ? onSubmit : null, ), - 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: selectedHub, - icon: const Icon( - UiIcons.chevronDown, - size: 18, - color: UiColors.iconSecondary, - ), - onChanged: (OrderHubUiModel? hub) { - if (hub != null) { - onHubChanged(hub); - } - }, - items: hubs.map((OrderHubUiModel hub) { - return DropdownMenuItem( - value: hub, - child: Text(hub.name, style: UiTypography.body2m.textPrimary), - ); - }).toList(), - ), - ), - ), - const SizedBox(height: UiConstants.space4), - - HubManagerSelector( - label: labels.hub_manager_label, - description: labels.hub_manager_desc, - hintText: labels.hub_manager_hint, - noManagersText: labels.hub_manager_empty, - noneText: labels.hub_manager_none, - managers: hubManagers, - selectedManager: selectedHubManager, - onChanged: onHubManagerChanged, - ), - const SizedBox(height: UiConstants.space6), - - OneTimeOrderSectionHeader( - title: labels.positions_title, - actionLabel: labels.add_position, - onAction: onPositionAdded, - ), - const SizedBox(height: UiConstants.space3), - - // Positions List - ...positions.asMap().entries.map(( - MapEntry entry, - ) { - final int index = entry.key; - final OrderPositionUiModel position = entry.value; - return Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space3), - child: OneTimeOrderPositionCard( - index: index, - position: position, - isRemovable: positions.length > 1, - positionLabel: labels.positions_title, - roleLabel: labels.select_role, - workersLabel: labels.workers_label, - startLabel: labels.start_label, - endLabel: labels.end_label, - lunchLabel: labels.lunch_break_label, - roles: roles, - onUpdated: (OrderPositionUiModel updated) { - onPositionUpdated(index, updated); - }, - onRemoved: () { - onPositionRemoved(index); - }, - ), - ); - }), ], ); } } - -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/orders/orders_common/lib/src/presentation/widgets/order_bottom_action_button.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_bottom_action_button.dart new file mode 100644 index 00000000..03f7ffd8 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_bottom_action_button.dart @@ -0,0 +1,49 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A bottom-pinned action button used across all order type views. +/// +/// Renders a full-width primary button with safe-area padding at the bottom +/// and a top border separator. Disables the button while [isLoading] is true. +class OrderBottomActionButton extends StatelessWidget { + /// Creates an [OrderBottomActionButton]. + const OrderBottomActionButton({ + required this.label, + required this.onPressed, + this.isLoading = false, + super.key, + }); + + /// The text displayed on the button. + final String label; + + /// Callback invoked when the button is pressed. Pass `null` to disable. + final VoidCallback? onPressed; + + /// Whether the form is currently submitting. Disables the button when true. + 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, width: 0.5)), + ), + 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/orders/orders_common/lib/src/presentation/widgets/order_form_skeleton.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_form_skeleton.dart new file mode 100644 index 00000000..291fcf59 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_form_skeleton.dart @@ -0,0 +1,144 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer skeleton that mimics the order creation form layout. +/// +/// Displayed while initial data (vendors, hubs, roles) is being fetched. +/// Renders placeholder shapes for the text input, dropdowns, date picker, +/// hub manager section, and one position card. +class OrderFormSkeleton extends StatelessWidget { + /// Creates an [OrderFormSkeleton]. + const OrderFormSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: ListView( + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.all(UiConstants.space5), + children: [ + _buildLabelPlaceholder(), + const SizedBox(height: UiConstants.space2), + _buildTextFieldPlaceholder(), + const SizedBox(height: UiConstants.space4), + _buildLabelPlaceholder(), + const SizedBox(height: UiConstants.space2), + _buildDropdownPlaceholder(), + const SizedBox(height: UiConstants.space4), + _buildLabelPlaceholder(), + const SizedBox(height: UiConstants.space2), + _buildDropdownPlaceholder(), + const SizedBox(height: UiConstants.space4), + _buildLabelPlaceholder(), + const SizedBox(height: UiConstants.space2), + _buildDropdownPlaceholder(), + const SizedBox(height: UiConstants.space4), + _buildHubManagerPlaceholder(), + const SizedBox(height: UiConstants.space6), + _buildSectionHeaderPlaceholder(), + const SizedBox(height: UiConstants.space3), + _buildPositionCardPlaceholder(), + ], + ), + ); + } + + /// Small label placeholder above each field. + Widget _buildLabelPlaceholder() { + return const Align( + alignment: Alignment.centerLeft, + child: UiShimmerLine(width: 100, height: 12), + ); + } + + /// Full-width text input placeholder. + Widget _buildTextFieldPlaceholder() { + return const UiShimmerBox(width: double.infinity, height: 48); + } + + /// Full-width dropdown selector placeholder. + Widget _buildDropdownPlaceholder() { + return const UiShimmerBox(width: double.infinity, height: 48); + } + + /// Hub manager section with label and description lines. + Widget _buildHubManagerPlaceholder() { + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 140, height: 12), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 220, height: 10), + SizedBox(height: UiConstants.space2), + UiShimmerBox(width: double.infinity, height: 48), + ], + ); + } + + /// Section header placeholder with title and action button. + Widget _buildSectionHeaderPlaceholder() { + return const Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + UiShimmerLine(width: 100, height: 16), + UiShimmerBox(width: 90, height: 28), + ], + ); + } + + /// Position card placeholder mimicking role, worker count, and time fields. + Widget _buildPositionCardPlaceholder() { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + UiShimmerLine(width: 80, height: 14), + UiShimmerCircle(size: 24), + ], + ), + const SizedBox(height: UiConstants.space3), + const UiShimmerBox(width: double.infinity, height: 44), + const SizedBox(height: UiConstants.space3), + const UiShimmerLine(width: 60, height: 12), + const SizedBox(height: UiConstants.space2), + const UiShimmerBox(width: double.infinity, height: 44), + const SizedBox(height: UiConstants.space3), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + UiShimmerLine(width: 50, height: 12), + SizedBox(height: UiConstants.space2), + UiShimmerBox(width: double.infinity, height: 44), + ], + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + UiShimmerLine(width: 50, height: 12), + SizedBox(height: UiConstants.space2), + UiShimmerBox(width: double.infinity, height: 44), + ], + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_days_selector.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_days_selector.dart new file mode 100644 index 00000000..37fbd915 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_days_selector.dart @@ -0,0 +1,68 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A horizontal row of circular day-of-week toggle buttons for permanent orders. +/// +/// Displays seven circles labeled S, M, T, W, T, F, S representing the days +/// of the week. Selected days are highlighted with the primary color. +class PermanentOrderDaysSelector extends StatelessWidget { + /// Creates a [PermanentOrderDaysSelector]. + const PermanentOrderDaysSelector({ + required this.selectedDays, + required this.onToggle, + super.key, + }); + + /// The list of currently selected day abbreviations (e.g. 'MON', 'TUE'). + final List selectedDays; + + /// Called when a day circle is tapped, with the day index (0 = Sunday). + 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, + ), + ), + ), + ); + }), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_form.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_form.dart new file mode 100644 index 00000000..36d7ba08 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_form.dart @@ -0,0 +1,263 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart' show Vendor; + +import '../hub_manager_selector.dart'; +import '../order_ui_models.dart'; +import 'permanent_order_date_picker.dart'; +import 'permanent_order_days_selector.dart'; +import 'permanent_order_event_name_input.dart'; +import 'permanent_order_position_card.dart'; +import 'permanent_order_section_header.dart'; + +/// The scrollable form body for the permanent order creation flow. +/// +/// Displays fields for event name, vendor selection, start date, +/// permanent day toggles, hub, hub manager, and a dynamic list of +/// position cards. +class PermanentOrderForm extends StatelessWidget { + /// Creates a [PermanentOrderForm]. + const PermanentOrderForm({ + required this.eventName, + required this.selectedVendor, + required this.vendors, + required this.startDate, + required this.permanentDays, + required this.selectedHub, + required this.hubs, + required this.positions, + required this.roles, + required this.onEventNameChanged, + required this.onVendorChanged, + required this.onStartDateChanged, + required this.onDayToggled, + required this.onHubChanged, + required this.onHubManagerChanged, + required this.onPositionAdded, + required this.onPositionUpdated, + required this.onPositionRemoved, + required this.hubManagers, + required this.selectedHubManager, + super.key, + }); + + /// The current event name value. + final String eventName; + + /// The currently selected vendor, if any. + final Vendor? selectedVendor; + + /// The list of available vendors to choose from. + final List vendors; + + /// The start date for the permanent order. + final DateTime startDate; + + /// The list of selected permanent day abbreviations (e.g. 'MON', 'TUE'). + final List permanentDays; + + /// The currently selected hub, if any. + final OrderHubUiModel? selectedHub; + + /// The list of available hubs to choose from. + final List hubs; + + /// The list of position entries in the order. + final List positions; + + /// The list of available roles for position assignment. + final List roles; + + /// Called when the event name text changes. + final ValueChanged onEventNameChanged; + + /// Called when a vendor is selected. + final ValueChanged onVendorChanged; + + /// Called when the start date is changed. + final ValueChanged onStartDateChanged; + + /// Called when a day-of-week toggle is tapped, with the day index (0=Sun). + final ValueChanged onDayToggled; + + /// Called when a hub is selected. + final ValueChanged onHubChanged; + + /// Called when a hub manager is selected or cleared. + final ValueChanged onHubManagerChanged; + + /// Called when the user requests adding a new position. + final VoidCallback onPositionAdded; + + /// Called when a position at [index] is updated with new values. + final void Function(int index, OrderPositionUiModel position) + onPositionUpdated; + + /// Called when a position at [index] is removed. + final void Function(int index) onPositionRemoved; + + /// The list of available hub managers for the selected hub. + final List hubManagers; + + /// The currently selected hub manager, if any. + final OrderManagerUiModel? selectedHubManager; + + @override + Widget build(BuildContext context) { + final TranslationsClientCreateOrderOneTimeEn oneTimeLabels = + t.client_create_order.one_time; + + return ListView( + padding: const EdgeInsets.all(UiConstants.space5), + children: [ + PermanentOrderEventNameInput( + label: 'ORDER NAME', + value: eventName, + onChanged: onEventNameChanged, + ), + 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: selectedVendor, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (Vendor? vendor) { + if (vendor != null) { + onVendorChanged(vendor); + } + }, + items: 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: startDate, + onChanged: onStartDateChanged, + ), + const SizedBox(height: UiConstants.space4), + + Text('Permanent Days', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + PermanentOrderDaysSelector( + selectedDays: permanentDays, + onToggle: onDayToggled, + ), + 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: selectedHub, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (OrderHubUiModel? hub) { + if (hub != null) { + onHubChanged(hub); + } + }, + items: hubs.map((OrderHubUiModel hub) { + return DropdownMenuItem( + value: hub, + child: Text( + hub.name, + style: UiTypography.body2m.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space4), + + HubManagerSelector( + label: oneTimeLabels.hub_manager_label, + description: oneTimeLabels.hub_manager_desc, + hintText: oneTimeLabels.hub_manager_hint, + noManagersText: oneTimeLabels.hub_manager_empty, + noneText: oneTimeLabels.hub_manager_none, + managers: hubManagers, + selectedManager: selectedHubManager, + onChanged: onHubManagerChanged, + ), + const SizedBox(height: UiConstants.space6), + + PermanentOrderSectionHeader( + title: oneTimeLabels.positions_title, + actionLabel: oneTimeLabels.add_position, + onAction: onPositionAdded, + ), + const SizedBox(height: UiConstants.space3), + + // Positions List + ...positions.asMap().entries.map(( + MapEntry entry, + ) { + final int index = entry.key; + final OrderPositionUiModel position = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: PermanentOrderPositionCard( + index: index, + position: position, + isRemovable: 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: roles, + onUpdated: (OrderPositionUiModel updated) { + onPositionUpdated(index, updated); + }, + onRemoved: () { + onPositionRemoved(index); + }, + ), + ); + }), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_header.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_header.dart deleted file mode 100644 index 8943f5f1..00000000 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_header.dart +++ /dev/null @@ -1,71 +0,0 @@ -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/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart index abcf7a20..5a253eb0 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart @@ -2,13 +2,11 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart' show Vendor; + +import '../order_bottom_action_button.dart'; +import '../order_form_skeleton.dart'; import '../order_ui_models.dart'; -import '../hub_manager_selector.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_form.dart'; import 'permanent_order_success_view.dart'; /// The main content of the Permanent Order page. @@ -40,9 +38,12 @@ class PermanentOrderView extends StatelessWidget { required this.onSubmit, required this.onDone, required this.onBack, + this.isDataLoaded = true, super.key, }); + /// Whether initial data (vendors, hubs) has been fetched from the backend. + final bool isDataLoaded; final OrderFormStatus status; final String? errorMessage; final String eventName; @@ -65,7 +66,8 @@ class PermanentOrderView extends StatelessWidget { final ValueChanged onHubChanged; final ValueChanged onHubManagerChanged; final VoidCallback onPositionAdded; - final void Function(int index, OrderPositionUiModel position) onPositionUpdated; + final void Function(int index, OrderPositionUiModel position) + onPositionUpdated; final void Function(int index) onPositionRemoved; final VoidCallback onSubmit; final VoidCallback onDone; @@ -84,7 +86,12 @@ class PermanentOrderView extends StatelessWidget { context, message: translateErrorKey(errorMessage!), type: UiSnackbarType.error, - margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16), + // bottom: 140 clears the bottom navigation bar area + margin: const EdgeInsets.only( + bottom: 140, + left: UiConstants.space4, + right: UiConstants.space4, + ), ); }); } @@ -98,398 +105,99 @@ class PermanentOrderView extends StatelessWidget { ); } + return Scaffold( + appBar: UiAppBar( + showBackButton: true, + onLeadingPressed: onBack, + title: labels.title, + subtitle: labels.subtitle, + ), + body: _buildBody(context, labels, oneTimeLabels), + ); + } + + /// Builds the main body of the Permanent Order page based on the current state. + Widget _buildBody( + BuildContext context, + TranslationsClientCreateOrderPermanentEn labels, + TranslationsClientCreateOrderOneTimeEn oneTimeLabels, + ) { + if (!isDataLoaded) { + return const OrderFormSkeleton(); + } + if (vendors.isEmpty && status != OrderFormStatus.loading) { - return Scaffold( - body: Column( - children: [ - PermanentOrderHeader( - title: labels.title, - subtitle: labels.subtitle, - onBack: onBack, - ), - 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 Column( + children: [ + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + UiIcons.search, + size: 64, + color: UiColors.iconInactive, + ), + const SizedBox(height: UiConstants.space4), + Text( + t.client_create_order.no_vendors_title, + style: UiTypography.headline3m.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + Text( + t.client_create_order.no_vendors_description, + style: UiTypography.body2r.textSecondary, + textAlign: TextAlign.center, + ), + ], ), ), - ], - ), + ), + ], ); } - return Scaffold( - body: Column( - children: [ - PermanentOrderHeader( - title: labels.title, - subtitle: labels.subtitle, - onBack: onBack, - ), - Expanded( - child: Stack( - children: [ - _PermanentOrderForm( - eventName: eventName, - selectedVendor: selectedVendor, - vendors: vendors, - startDate: startDate, - permanentDays: permanentDays, - selectedHub: selectedHub, - hubs: hubs, - positions: positions, - roles: roles, - onEventNameChanged: onEventNameChanged, - onVendorChanged: onVendorChanged, - onStartDateChanged: onStartDateChanged, - onDayToggled: onDayToggled, - onHubChanged: onHubChanged, - onHubManagerChanged: onHubManagerChanged, - onPositionAdded: onPositionAdded, - onPositionUpdated: onPositionUpdated, - onPositionRemoved: onPositionRemoved, - hubManagers: hubManagers, - selectedHubManager: selectedHubManager, - ), - if (status == OrderFormStatus.loading) - const Center(child: CircularProgressIndicator()), - ], - ), - ), - _BottomActionButton( - label: status == OrderFormStatus.loading - ? oneTimeLabels.creating - : oneTimeLabels.create_order, - isLoading: status == OrderFormStatus.loading, - onPressed: isValid ? onSubmit : null, - ), - ], - ), - ); - } -} - -class _PermanentOrderForm extends StatelessWidget { - const _PermanentOrderForm({ - required this.eventName, - required this.selectedVendor, - required this.vendors, - required this.startDate, - required this.permanentDays, - required this.selectedHub, - required this.hubs, - required this.positions, - required this.roles, - required this.onEventNameChanged, - required this.onVendorChanged, - required this.onStartDateChanged, - required this.onDayToggled, - required this.onHubChanged, - required this.onHubManagerChanged, - required this.onPositionAdded, - required this.onPositionUpdated, - required this.onPositionRemoved, - required this.hubManagers, - required this.selectedHubManager, - }); - - final String eventName; - final Vendor? selectedVendor; - final List vendors; - final DateTime startDate; - final List permanentDays; - final OrderHubUiModel? selectedHub; - final List hubs; - final List positions; - final List roles; - - final ValueChanged onEventNameChanged; - final ValueChanged onVendorChanged; - final ValueChanged onStartDateChanged; - final ValueChanged onDayToggled; - final ValueChanged onHubChanged; - final ValueChanged onHubManagerChanged; - final VoidCallback onPositionAdded; - final void Function(int index, OrderPositionUiModel position) onPositionUpdated; - final void Function(int index) onPositionRemoved; - - final List hubManagers; - final OrderManagerUiModel? selectedHubManager; - - @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), + return Column( children: [ - Text( - labels.title, - style: UiTypography.headline3m.textPrimary, - ), - const SizedBox(height: UiConstants.space4), - - PermanentOrderEventNameInput( - label: 'ORDER NAME', - value: eventName, - onChanged: onEventNameChanged, - ), - 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: selectedVendor, - icon: const Icon( - UiIcons.chevronDown, - size: 18, - color: UiColors.iconSecondary, + Expanded( + child: Stack( + children: [ + PermanentOrderForm( + eventName: eventName, + selectedVendor: selectedVendor, + vendors: vendors, + startDate: startDate, + permanentDays: permanentDays, + selectedHub: selectedHub, + hubs: hubs, + positions: positions, + roles: roles, + onEventNameChanged: onEventNameChanged, + onVendorChanged: onVendorChanged, + onStartDateChanged: onStartDateChanged, + onDayToggled: onDayToggled, + onHubChanged: onHubChanged, + onHubManagerChanged: onHubManagerChanged, + onPositionAdded: onPositionAdded, + onPositionUpdated: onPositionUpdated, + onPositionRemoved: onPositionRemoved, + hubManagers: hubManagers, + selectedHubManager: selectedHubManager, ), - onChanged: (Vendor? vendor) { - if (vendor != null) { - onVendorChanged(vendor); - } - }, - items: vendors.map((Vendor vendor) { - return DropdownMenuItem( - value: vendor, - child: Text( - vendor.name, - style: UiTypography.body2m.textPrimary, - ), - ); - }).toList(), - ), + if (status == OrderFormStatus.loading) + const Center(child: CircularProgressIndicator()), + ], ), ), - const SizedBox(height: UiConstants.space4), - - PermanentOrderDatePicker( - label: 'Start Date', - value: startDate, - onChanged: onStartDateChanged, + OrderBottomActionButton( + label: status == OrderFormStatus.loading + ? oneTimeLabels.creating + : oneTimeLabels.create_order, + isLoading: status == OrderFormStatus.loading, + onPressed: isValid ? onSubmit : null, ), - const SizedBox(height: UiConstants.space4), - - Text('Permanent Days', style: UiTypography.footnote2r.textSecondary), - const SizedBox(height: UiConstants.space2), - _PermanentDaysSelector( - selectedDays: permanentDays, - onToggle: onDayToggled, - ), - 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: selectedHub, - icon: const Icon( - UiIcons.chevronDown, - size: 18, - color: UiColors.iconSecondary, - ), - onChanged: (OrderHubUiModel? hub) { - if (hub != null) { - onHubChanged(hub); - } - }, - items: hubs.map((OrderHubUiModel hub) { - return DropdownMenuItem( - value: hub, - child: Text( - hub.name, - style: UiTypography.body2m.textPrimary, - ), - ); - }).toList(), - ), - ), - ), - const SizedBox(height: UiConstants.space4), - - HubManagerSelector( - label: oneTimeLabels.hub_manager_label, - description: oneTimeLabels.hub_manager_desc, - hintText: oneTimeLabels.hub_manager_hint, - noManagersText: oneTimeLabels.hub_manager_empty, - noneText: oneTimeLabels.hub_manager_none, - managers: hubManagers, - selectedManager: selectedHubManager, - onChanged: onHubManagerChanged, - ), - const SizedBox(height: UiConstants.space6), - - PermanentOrderSectionHeader( - title: oneTimeLabels.positions_title, - actionLabel: oneTimeLabels.add_position, - onAction: onPositionAdded, - ), - const SizedBox(height: UiConstants.space3), - - // Positions List - ...positions.asMap().entries.map(( - MapEntry entry, - ) { - final int index = entry.key; - final OrderPositionUiModel position = entry.value; - return Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space3), - child: PermanentOrderPositionCard( - index: index, - position: position, - isRemovable: 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: roles, - onUpdated: (OrderPositionUiModel updated) { - onPositionUpdated(index, updated); - }, - onRemoved: () { - onPositionRemoved(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/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_days_selector.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_days_selector.dart new file mode 100644 index 00000000..08ce04c4 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_days_selector.dart @@ -0,0 +1,68 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A horizontal row of circular day-of-week toggle buttons for recurring orders. +/// +/// Displays seven circles labeled S, M, T, W, T, F, S representing the days +/// of the week. Selected days are highlighted with the primary color. +class RecurringOrderDaysSelector extends StatelessWidget { + /// Creates a [RecurringOrderDaysSelector]. + const RecurringOrderDaysSelector({ + required this.selectedDays, + required this.onToggle, + super.key, + }); + + /// The list of currently selected day abbreviations (e.g. 'MON', 'TUE'). + final List selectedDays; + + /// Called when a day circle is tapped, with the day index (0 = Sunday). + 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, + ), + ), + ), + ); + }), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_form.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_form.dart new file mode 100644 index 00000000..2bc274bc --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_form.dart @@ -0,0 +1,275 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart' show Vendor; + +import '../hub_manager_selector.dart'; +import '../order_ui_models.dart'; +import 'recurring_order_date_picker.dart'; +import 'recurring_order_days_selector.dart'; +import 'recurring_order_event_name_input.dart'; +import 'recurring_order_position_card.dart'; +import 'recurring_order_section_header.dart'; + +/// The scrollable form body for the recurring order creation flow. +/// +/// Displays fields for event name, vendor selection, start/end dates, +/// recurring day toggles, hub, hub manager, and a dynamic list of +/// position cards. +class RecurringOrderForm extends StatelessWidget { + /// Creates a [RecurringOrderForm]. + const RecurringOrderForm({ + required this.eventName, + required this.selectedVendor, + required this.vendors, + required this.startDate, + required this.endDate, + required this.recurringDays, + required this.selectedHub, + required this.hubs, + required this.positions, + required this.roles, + required this.onEventNameChanged, + required this.onVendorChanged, + required this.onStartDateChanged, + required this.onEndDateChanged, + required this.onDayToggled, + required this.onHubChanged, + required this.onHubManagerChanged, + required this.onPositionAdded, + required this.onPositionUpdated, + required this.onPositionRemoved, + required this.hubManagers, + required this.selectedHubManager, + super.key, + }); + + /// The current event name value. + final String eventName; + + /// The currently selected vendor, if any. + final Vendor? selectedVendor; + + /// The list of available vendors to choose from. + final List vendors; + + /// The start date for the recurring period. + final DateTime startDate; + + /// The end date for the recurring period. + final DateTime endDate; + + /// The list of selected recurring day abbreviations (e.g. 'MON', 'TUE'). + final List recurringDays; + + /// The currently selected hub, if any. + final OrderHubUiModel? selectedHub; + + /// The list of available hubs to choose from. + final List hubs; + + /// The list of position entries in the order. + final List positions; + + /// The list of available roles for position assignment. + final List roles; + + /// Called when the event name text changes. + final ValueChanged onEventNameChanged; + + /// Called when a vendor is selected. + final ValueChanged onVendorChanged; + + /// Called when the start date is changed. + final ValueChanged onStartDateChanged; + + /// Called when the end date is changed. + final ValueChanged onEndDateChanged; + + /// Called when a day-of-week toggle is tapped, with the day index (0=Sun). + final ValueChanged onDayToggled; + + /// Called when a hub is selected. + final ValueChanged onHubChanged; + + /// Called when a hub manager is selected or cleared. + final ValueChanged onHubManagerChanged; + + /// Called when the user requests adding a new position. + final VoidCallback onPositionAdded; + + /// Called when a position at [index] is updated with new values. + final void Function(int index, OrderPositionUiModel position) + onPositionUpdated; + + /// Called when a position at [index] is removed. + final void Function(int index) onPositionRemoved; + + /// The list of available hub managers for the selected hub. + final List hubManagers; + + /// The currently selected hub manager, if any. + final OrderManagerUiModel? selectedHubManager; + + @override + Widget build(BuildContext context) { + final TranslationsClientCreateOrderOneTimeEn oneTimeLabels = + t.client_create_order.one_time; + + return ListView( + padding: const EdgeInsets.all(UiConstants.space5), + children: [ + RecurringOrderEventNameInput( + label: 'ORDER NAME', + value: eventName, + onChanged: onEventNameChanged, + ), + 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: selectedVendor, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (Vendor? vendor) { + if (vendor != null) { + onVendorChanged(vendor); + } + }, + items: 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: startDate, + onChanged: onStartDateChanged, + ), + const SizedBox(height: UiConstants.space4), + + RecurringOrderDatePicker( + label: 'End Date', + value: endDate, + onChanged: onEndDateChanged, + ), + const SizedBox(height: UiConstants.space4), + + Text('Recurring Days', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + RecurringOrderDaysSelector( + selectedDays: recurringDays, + onToggle: onDayToggled, + ), + 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: selectedHub, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (OrderHubUiModel? hub) { + if (hub != null) { + onHubChanged(hub); + } + }, + items: hubs.map((OrderHubUiModel hub) { + return DropdownMenuItem( + value: hub, + child: Text(hub.name, style: UiTypography.body2m.textPrimary), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space6), + + HubManagerSelector( + label: oneTimeLabels.hub_manager_label, + description: oneTimeLabels.hub_manager_desc, + hintText: oneTimeLabels.hub_manager_hint, + noManagersText: oneTimeLabels.hub_manager_empty, + noneText: oneTimeLabels.hub_manager_none, + managers: hubManagers, + selectedManager: selectedHubManager, + onChanged: onHubManagerChanged, + ), + const SizedBox(height: UiConstants.space6), + + RecurringOrderSectionHeader( + title: oneTimeLabels.positions_title, + actionLabel: oneTimeLabels.add_position, + onAction: onPositionAdded, + ), + const SizedBox(height: UiConstants.space3), + + // Positions List + ...positions.asMap().entries.map(( + MapEntry entry, + ) { + final int index = entry.key; + final OrderPositionUiModel position = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: RecurringOrderPositionCard( + index: index, + position: position, + isRemovable: 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: roles, + onUpdated: (OrderPositionUiModel updated) { + onPositionUpdated(index, updated); + }, + onRemoved: () { + onPositionRemoved(index); + }, + ), + ); + }), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_header.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_header.dart deleted file mode 100644 index 5913b205..00000000 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_header.dart +++ /dev/null @@ -1,71 +0,0 @@ -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/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart index fbc00c07..d5d2e469 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart @@ -1,14 +1,12 @@ 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:krow_domain/krow_domain.dart' show Vendor; + +import '../order_bottom_action_button.dart'; +import '../order_form_skeleton.dart'; import '../order_ui_models.dart'; -import '../hub_manager_selector.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_form.dart'; import 'recurring_order_success_view.dart'; /// The main content of the Recurring Order page. @@ -42,9 +40,12 @@ class RecurringOrderView extends StatelessWidget { required this.onSubmit, required this.onDone, required this.onBack, + this.isDataLoaded = true, super.key, }); + /// Whether initial data (vendors, hubs) has been fetched from the backend. + final bool isDataLoaded; final OrderFormStatus status; final String? errorMessage; final String eventName; @@ -69,7 +70,8 @@ class RecurringOrderView extends StatelessWidget { final ValueChanged onHubChanged; final ValueChanged onHubManagerChanged; final VoidCallback onPositionAdded; - final void Function(int index, OrderPositionUiModel position) onPositionUpdated; + final void Function(int index, OrderPositionUiModel position) + onPositionUpdated; final void Function(int index) onPositionRemoved; final VoidCallback onSubmit; final VoidCallback onDone; @@ -91,7 +93,12 @@ class RecurringOrderView extends StatelessWidget { context, message: message, type: UiSnackbarType.error, - margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16), + // bottom: 140 clears the bottom navigation bar area + margin: const EdgeInsets.only( + bottom: 140, + left: UiConstants.space4, + right: UiConstants.space4, + ), ); }); } @@ -105,412 +112,101 @@ class RecurringOrderView extends StatelessWidget { ); } + return Scaffold( + appBar: UiAppBar( + showBackButton: true, + onLeadingPressed: onBack, + title: labels.title, + subtitle: labels.subtitle, + ), + body: _buildBody(context, labels, oneTimeLabels), + ); + } + + /// Builds the main body of the Recurring Order page, including the form and handling empty vendor state. + Widget _buildBody( + BuildContext context, + TranslationsClientCreateOrderRecurringEn labels, + TranslationsClientCreateOrderOneTimeEn oneTimeLabels, + ) { + if (!isDataLoaded) { + return const OrderFormSkeleton(); + } + if (vendors.isEmpty && status != OrderFormStatus.loading) { - return Scaffold( - body: Column( - children: [ - RecurringOrderHeader( - title: labels.title, - subtitle: labels.subtitle, - onBack: onBack, - ), - 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 Column( + children: [ + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + UiIcons.search, + size: 64, + color: UiColors.iconInactive, + ), + const SizedBox(height: UiConstants.space4), + Text( + t.client_create_order.no_vendors_title, + style: UiTypography.headline3m.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + Text( + t.client_create_order.no_vendors_description, + style: UiTypography.body2r.textSecondary, + textAlign: TextAlign.center, + ), + ], ), ), - ], - ), + ), + ], ); } - return Scaffold( - body: Column( - children: [ - RecurringOrderHeader( - title: labels.title, - subtitle: labels.subtitle, - onBack: onBack, - ), - Expanded( - child: Stack( - children: [ - _RecurringOrderForm( - eventName: eventName, - selectedVendor: selectedVendor, - vendors: vendors, - startDate: startDate, - endDate: endDate, - recurringDays: recurringDays, - selectedHub: selectedHub, - hubs: hubs, - positions: positions, - roles: roles, - onEventNameChanged: onEventNameChanged, - onVendorChanged: onVendorChanged, - onStartDateChanged: onStartDateChanged, - onEndDateChanged: onEndDateChanged, - onDayToggled: onDayToggled, - onHubChanged: onHubChanged, - onHubManagerChanged: onHubManagerChanged, - onPositionAdded: onPositionAdded, - onPositionUpdated: onPositionUpdated, - onPositionRemoved: onPositionRemoved, - hubManagers: hubManagers, - selectedHubManager: selectedHubManager, - ), - if (status == OrderFormStatus.loading) - const Center(child: CircularProgressIndicator()), - ], - ), - ), - _BottomActionButton( - label: status == OrderFormStatus.loading - ? oneTimeLabels.creating - : oneTimeLabels.create_order, - isLoading: status == OrderFormStatus.loading, - onPressed: isValid ? onSubmit : null, - ), - ], - ), - ); - } -} - -class _RecurringOrderForm extends StatelessWidget { - const _RecurringOrderForm({ - required this.eventName, - required this.selectedVendor, - required this.vendors, - required this.startDate, - required this.endDate, - required this.recurringDays, - required this.selectedHub, - required this.hubs, - required this.positions, - required this.roles, - required this.onEventNameChanged, - required this.onVendorChanged, - required this.onStartDateChanged, - required this.onEndDateChanged, - required this.onDayToggled, - required this.onHubChanged, - required this.onHubManagerChanged, - required this.onPositionAdded, - required this.onPositionUpdated, - required this.onPositionRemoved, - required this.hubManagers, - required this.selectedHubManager, - }); - - final String eventName; - final Vendor? selectedVendor; - final List vendors; - final DateTime startDate; - final DateTime endDate; - final List recurringDays; - final OrderHubUiModel? selectedHub; - final List hubs; - final List positions; - final List roles; - - final ValueChanged onEventNameChanged; - final ValueChanged onVendorChanged; - final ValueChanged onStartDateChanged; - final ValueChanged onEndDateChanged; - final ValueChanged onDayToggled; - final ValueChanged onHubChanged; - final ValueChanged onHubManagerChanged; - final VoidCallback onPositionAdded; - final void Function(int index, OrderPositionUiModel position) onPositionUpdated; - final void Function(int index) onPositionRemoved; - - final List hubManagers; - final OrderManagerUiModel? selectedHubManager; - - - @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), + return Column( children: [ - Text( - labels.title, - style: UiTypography.headline3m.textPrimary, - ), - const SizedBox(height: UiConstants.space4), - - RecurringOrderEventNameInput( - label: 'ORDER NAME', - value: eventName, - onChanged: onEventNameChanged, - ), - 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: selectedVendor, - icon: const Icon( - UiIcons.chevronDown, - size: 18, - color: UiColors.iconSecondary, + Expanded( + child: Stack( + children: [ + RecurringOrderForm( + eventName: eventName, + selectedVendor: selectedVendor, + vendors: vendors, + startDate: startDate, + endDate: endDate, + recurringDays: recurringDays, + selectedHub: selectedHub, + hubs: hubs, + positions: positions, + roles: roles, + onEventNameChanged: onEventNameChanged, + onVendorChanged: onVendorChanged, + onStartDateChanged: onStartDateChanged, + onEndDateChanged: onEndDateChanged, + onDayToggled: onDayToggled, + onHubChanged: onHubChanged, + onHubManagerChanged: onHubManagerChanged, + onPositionAdded: onPositionAdded, + onPositionUpdated: onPositionUpdated, + onPositionRemoved: onPositionRemoved, + hubManagers: hubManagers, + selectedHubManager: selectedHubManager, ), - onChanged: (Vendor? vendor) { - if (vendor != null) { - onVendorChanged(vendor); - } - }, - items: vendors.map((Vendor vendor) { - return DropdownMenuItem( - value: vendor, - child: Text( - vendor.name, - style: UiTypography.body2m.textPrimary, - ), - ); - }).toList(), - ), + if (status == OrderFormStatus.loading) + const Center(child: CircularProgressIndicator()), + ], ), ), - const SizedBox(height: UiConstants.space4), - - RecurringOrderDatePicker( - label: 'Start Date', - value: startDate, - onChanged: onStartDateChanged, + OrderBottomActionButton( + label: status == OrderFormStatus.loading + ? oneTimeLabels.creating + : oneTimeLabels.create_order, + isLoading: status == OrderFormStatus.loading, + onPressed: isValid ? onSubmit : null, ), - const SizedBox(height: UiConstants.space4), - - RecurringOrderDatePicker( - label: 'End Date', - value: endDate, - onChanged: onEndDateChanged, - ), - const SizedBox(height: UiConstants.space4), - - Text('Recurring Days', style: UiTypography.footnote2r.textSecondary), - const SizedBox(height: UiConstants.space2), - _RecurringDaysSelector( - selectedDays: recurringDays, - onToggle: onDayToggled, - ), - 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: selectedHub, - icon: const Icon( - UiIcons.chevronDown, - size: 18, - color: UiColors.iconSecondary, - ), - onChanged: (OrderHubUiModel? hub) { - if (hub != null) { - onHubChanged(hub); - } - }, - items: hubs.map((OrderHubUiModel hub) { - return DropdownMenuItem( - value: hub, - child: Text( - hub.name, - style: UiTypography.body2m.textPrimary, - ), - ); - }).toList(), - ), - ), - ), - const SizedBox(height: UiConstants.space4), - - HubManagerSelector( - label: oneTimeLabels.hub_manager_label, - description: oneTimeLabels.hub_manager_desc, - hintText: oneTimeLabels.hub_manager_hint, - noManagersText: oneTimeLabels.hub_manager_empty, - noneText: oneTimeLabels.hub_manager_none, - managers: hubManagers, - selectedManager: selectedHubManager, - onChanged: onHubManagerChanged, - ), - const SizedBox(height: UiConstants.space6), - - RecurringOrderSectionHeader( - title: oneTimeLabels.positions_title, - actionLabel: oneTimeLabels.add_position, - onAction: onPositionAdded, - ), - const SizedBox(height: UiConstants.space3), - - // Positions List - ...positions.asMap().entries.map(( - MapEntry entry, - ) { - final int index = entry.key; - final OrderPositionUiModel position = entry.value; - return Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space3), - child: RecurringOrderPositionCard( - index: index, - position: position, - isRemovable: 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: roles, - onUpdated: (OrderPositionUiModel updated) { - onPositionUpdated(index, updated); - }, - onRemoved: () { - onPositionRemoved(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/client/orders/view_orders/lib/src/presentation/pages/view_orders_page.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/pages/view_orders_page.dart index 6c0a8923..32e317e7 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/pages/view_orders_page.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/pages/view_orders_page.dart @@ -11,6 +11,7 @@ import '../widgets/view_orders_header.dart'; import '../widgets/view_orders_empty_state.dart'; import '../widgets/view_orders_error_state.dart'; import '../widgets/view_orders_list.dart'; +import '../widgets/view_orders_page_skeleton.dart'; /// The main page for viewing client orders. /// @@ -101,20 +102,26 @@ class _ViewOrdersViewState extends State { // Content List Expanded( - child: state.status == ViewOrdersStatus.failure - ? ViewOrdersErrorState( - errorMessage: state.errorMessage, - selectedDate: state.selectedDate, - onRetry: () => BlocProvider.of( - context, - ).jumpToDate(state.selectedDate ?? DateTime.now()), - ) - : filteredOrders.isEmpty - ? ViewOrdersEmptyState(selectedDate: state.selectedDate) - : ViewOrdersList( - orders: filteredOrders, - filterTab: state.filterTab, - ), + child: switch (state.status) { + ViewOrdersStatus.loading || + ViewOrdersStatus.initial => + const ViewOrdersPageSkeleton(), + ViewOrdersStatus.failure => ViewOrdersErrorState( + errorMessage: state.errorMessage, + selectedDate: state.selectedDate, + onRetry: () => BlocProvider.of( + context, + ).jumpToDate(state.selectedDate ?? DateTime.now()), + ), + ViewOrdersStatus.success => filteredOrders.isEmpty + ? ViewOrdersEmptyState( + selectedDate: state.selectedDate, + ) + : ViewOrdersList( + orders: filteredOrders, + filterTab: state.filterTab, + ), + }, ), ], ), diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart index 689ec491..ca085684 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart @@ -161,14 +161,7 @@ class _ViewOrderCardState extends State { decoration: BoxDecoration( color: UiColors.white, borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border), - boxShadow: [ - BoxShadow( - color: UiColors.black.withValues(alpha: 0.04), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], + border: Border.all(color: UiColors.border, width: 0.5), ), child: Column( children: [ @@ -249,9 +242,12 @@ class _ViewOrderCardState extends State { size: 14, color: UiColors.iconSecondary, ), - Text( - order.eventName, - style: UiTypography.headline5m.textSecondary, + Expanded( + child: Text( + order.eventName, + style: UiTypography.headline5m.textSecondary, + overflow: TextOverflow.ellipsis, + ), ), ], ), @@ -313,7 +309,8 @@ class _ViewOrderCardState extends State { Expanded( child: Text( order.hubManagerName!, - style: UiTypography.footnote2r.textSecondary, + style: + UiTypography.footnote2r.textSecondary, maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -335,7 +332,8 @@ class _ViewOrderCardState extends State { bgColor: UiColors.primary.withValues(alpha: 0.08), onTap: () => _openEditSheet(order: order), ), - if (_canEditOrder(order)) const SizedBox(width: UiConstants.space2), + if (_canEditOrder(order)) + const SizedBox(width: UiConstants.space2), if (order.confirmedApps.isNotEmpty) _buildHeaderIconButton( icon: _expanded diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton.dart new file mode 100644 index 00000000..66f9a6da --- /dev/null +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton.dart @@ -0,0 +1 @@ +export 'view_orders_page_skeleton/index.dart'; diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/index.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/index.dart new file mode 100644 index 00000000..d64c5a98 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/index.dart @@ -0,0 +1,5 @@ +export 'order_card_skeleton.dart'; +export 'section_header_skeleton.dart'; +export 'stat_divider_skeleton.dart'; +export 'stat_item_skeleton.dart'; +export 'view_orders_page_skeleton.dart'; diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/order_card_skeleton.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/order_card_skeleton.dart new file mode 100644 index 00000000..8f1cf480 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/order_card_skeleton.dart @@ -0,0 +1,127 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'stat_divider_skeleton.dart'; +import 'stat_item_skeleton.dart'; + +/// Shimmer placeholder for a single order card. +class OrderCardSkeleton extends StatelessWidget { + /// Creates an [OrderCardSkeleton]. + const OrderCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + border: Border.all(color: UiColors.border, width: 0.5), + borderRadius: UiConstants.radiusLg, + ), + child: Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Status and type badges + Row( + children: [ + UiShimmerBox( + width: 80, + height: 22, + borderRadius: UiConstants.radiusSm, + ), + const SizedBox(width: UiConstants.space2), + UiShimmerBox( + width: 72, + height: 22, + borderRadius: UiConstants.radiusSm, + ), + ], + ), + const SizedBox(height: UiConstants.space3), + + // Title line + const UiShimmerLine(width: 200, height: 18), + const SizedBox(height: UiConstants.space2), + + // Event name line + const UiShimmerLine(width: 160, height: 14), + const SizedBox(height: UiConstants.space4), + + // Location lines + const Row( + children: [ + UiShimmerCircle(size: 14), + SizedBox(width: UiConstants.space2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 180, height: 12), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 140, height: 10), + ], + ), + ), + ], + ), + + const SizedBox(height: UiConstants.space4), + const Divider(height: 1, color: UiColors.border), + const SizedBox(height: UiConstants.space4), + + // Stats row (cost / hours / workers) + const Padding( + padding: EdgeInsets.symmetric( + horizontal: UiConstants.space4, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + StatItemSkeleton(), + StatDividerSkeleton(), + StatItemSkeleton(), + StatDividerSkeleton(), + StatItemSkeleton(), + ], + ), + ), + + const SizedBox(height: UiConstants.space5), + + // Time boxes (clock in / clock out) + Row( + children: [ + Expanded(child: _timeBoxSkeleton()), + const SizedBox(width: UiConstants.space3), + Expanded(child: _timeBoxSkeleton()), + ], + ), + + const SizedBox(height: UiConstants.space4), + + // Coverage progress bar + const UiShimmerLine(height: 8), + ], + ), + ), + ); + } + + /// Builds a placeholder for a time display box (clock-in / clock-out). + Widget _timeBoxSkeleton() { + return Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border, width: 0.5), + borderRadius: UiConstants.radiusLg, + ), + child: const Column( + children: [ + UiShimmerLine(width: 60, height: 10), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 80, height: 16), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/section_header_skeleton.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/section_header_skeleton.dart new file mode 100644 index 00000000..491b0c60 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/section_header_skeleton.dart @@ -0,0 +1,24 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the section header row (dot + title + count). +class SectionHeaderSkeleton extends StatelessWidget { + /// Creates a [SectionHeaderSkeleton]. + const SectionHeaderSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.only(bottom: UiConstants.space3), + child: Row( + children: [ + UiShimmerCircle(size: 8), + SizedBox(width: UiConstants.space2), + UiShimmerLine(width: 100, height: 14), + SizedBox(width: UiConstants.space1), + UiShimmerLine(width: 24, height: 14), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/stat_divider_skeleton.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/stat_divider_skeleton.dart new file mode 100644 index 00000000..b7b0878d --- /dev/null +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/stat_divider_skeleton.dart @@ -0,0 +1,17 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the vertical stat divider. +class StatDividerSkeleton extends StatelessWidget { + /// Creates a [StatDividerSkeleton]. + const StatDividerSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const UiShimmerBox( + width: 1, + height: 24, + borderRadius: BorderRadius.zero, + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/stat_item_skeleton.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/stat_item_skeleton.dart new file mode 100644 index 00000000..85cbe602 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/stat_item_skeleton.dart @@ -0,0 +1,20 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single stat item (icon + value + label). +class StatItemSkeleton extends StatelessWidget { + /// Creates a [StatItemSkeleton]. + const StatItemSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Column( + spacing: UiConstants.space1, + children: [ + UiShimmerCircle(size: 14), + UiShimmerLine(width: 32, height: 16), + UiShimmerLine(width: 40, height: 10), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/view_orders_page_skeleton.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/view_orders_page_skeleton.dart new file mode 100644 index 00000000..87f45b7d --- /dev/null +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/view_orders_page_skeleton.dart @@ -0,0 +1,41 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'order_card_skeleton.dart'; +import 'section_header_skeleton.dart'; + +/// Shimmer loading skeleton for the View Orders page. +/// +/// Mimics the loaded layout: a section header followed by a list of order +/// card placeholders, each containing badge, title, location, stats, time +/// boxes, and a coverage progress bar. +class ViewOrdersPageSkeleton extends StatelessWidget { + /// Creates a [ViewOrdersPageSkeleton]. + const ViewOrdersPageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: ListView( + padding: const EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space4, + UiConstants.space5, + // Extra bottom padding for bottom navigation clearance. + UiConstants.space24, + ), + children: [ + // Section header placeholder (dot + title + count) + const SectionHeaderSkeleton(), + // Order card placeholders + ...List.generate(3, (int index) { + return const Padding( + padding: EdgeInsets.only(bottom: UiConstants.space3), + child: OrderCardSkeleton(), + ); + }), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/view_orders_module.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/view_orders_module.dart index 6ba187d2..ec20567d 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/view_orders_module.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/view_orders_module.dart @@ -1,5 +1,6 @@ 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/view_orders_repository_impl.dart'; @@ -27,13 +28,13 @@ class ViewOrdersModule extends Module { i.add(GetAcceptedApplicationsForDayUseCase.new); // BLoCs - i.addSingleton(ViewOrdersCubit.new); + i.addLazySingleton(ViewOrdersCubit.new); } @override void routes(RouteManager r) { r.child( - '/', + ClientPaths.childRoute(ClientPaths.orders, ClientPaths.orders), child: (BuildContext context) { final Object? args = Modular.args.data; DateTime? initialDate; diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_bloc.dart index 943553bb..06f54dcb 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_bloc.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_bloc.dart @@ -1,5 +1,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:krow_domain/src/entities/reports/daily_ops_report.dart'; +import 'package:krow_domain/krow_domain.dart'; + import '../../../domain/repositories/reports_repository.dart'; import 'daily_ops_event.dart'; import 'daily_ops_state.dart'; diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart index ca7c9f5e..a6f3cdaf 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart @@ -1,14 +1,16 @@ -// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs -import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.dart'; import 'package:client_reports/src/presentation/blocs/coverage/coverage_event.dart'; import 'package:client_reports/src/presentation/blocs/coverage/coverage_state.dart'; -import 'package:krow_domain/krow_domain.dart'; 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:intl/intl.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../widgets/report_detail_skeleton.dart'; class CoverageReportPage extends StatefulWidget { const CoverageReportPage({super.key}); @@ -23,15 +25,14 @@ class _CoverageReportPageState extends State { @override Widget build(BuildContext context) { - return BlocProvider( + return BlocProvider( create: (BuildContext context) => Modular.get() ..add(LoadCoverageReport(startDate: _startDate, endDate: _endDate)), child: Scaffold( - backgroundColor: UiColors.bgMenu, body: BlocBuilder( builder: (BuildContext context, CoverageState state) { if (state is CoverageLoading) { - return const Center(child: CircularProgressIndicator()); + return const ReportDetailSkeleton(); } if (state is CoverageError) { @@ -64,7 +65,7 @@ class _CoverageReportPageState extends State { Row( children: [ GestureDetector( - onTap: () => Navigator.of(context).pop(), + onTap: () => Modular.to.popSafe(), child: Container( width: 40, height: 40, diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart index 07ede38c..03de178c 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart @@ -1,5 +1,4 @@ -// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports -import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_bloc.dart'; import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_event.dart'; import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_state.dart'; import 'package:core_localization/core_localization.dart'; @@ -8,7 +7,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:intl/intl.dart'; -import 'package:krow_domain/src/entities/reports/daily_ops_report.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../widgets/report_detail_skeleton.dart'; class DailyOpsReportPage extends StatefulWidget { const DailyOpsReportPage({super.key}); @@ -50,15 +52,14 @@ class _DailyOpsReportPageState extends State { @override Widget build(BuildContext context) { - return BlocProvider( + return BlocProvider( create: (BuildContext context) => Modular.get() ..add(LoadDailyOpsReport(date: _selectedDate)), child: Scaffold( - backgroundColor: UiColors.bgMenu, body: BlocBuilder( builder: (BuildContext context, DailyOpsState state) { if (state is DailyOpsLoading) { - return const Center(child: CircularProgressIndicator()); + return const ReportDetailSkeleton(); } if (state is DailyOpsError) { @@ -94,7 +95,7 @@ class _DailyOpsReportPageState extends State { Row( children: [ GestureDetector( - onTap: () => Navigator.of(context).pop(), + onTap: () => Modular.to.popSafe(), child: Container( width: 40, height: 40, @@ -245,6 +246,7 @@ class _DailyOpsReportPageState extends State { crossAxisCount: 2, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, mainAxisSpacing: 12, crossAxisSpacing: 12, childAspectRatio: 1.2, @@ -318,7 +320,7 @@ class _DailyOpsReportPageState extends State { ], ), - const SizedBox(height: 8), + const SizedBox(height: UiConstants.space8), Text( context.t.client_reports.daily_ops_report .all_shifts_title @@ -398,14 +400,8 @@ class _OpsStatCard extends StatelessWidget { padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: UiColors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: UiColors.black.withOpacity(0.06), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all(color: UiColors.border, width: 0.5), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -440,7 +436,8 @@ class _OpsStatCard extends StatelessWidget { color: UiColors.textPrimary, ), ), - const SizedBox(height: 6), + + //UiChip(label: subValue), // Colored pill badge (matches prototype) Container( padding: const EdgeInsets.symmetric( @@ -449,12 +446,12 @@ class _OpsStatCard extends StatelessWidget { ), decoration: BoxDecoration( color: color.withOpacity(0.12), - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(4), ), child: Text( subValue, style: TextStyle( - fontSize: 10, + fontSize: 12, fontWeight: FontWeight.bold, color: color, ), diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart index 553ca240..cd6ef84b 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart @@ -2,6 +2,7 @@ import 'package:client_reports/src/presentation/blocs/forecast/forecast_bloc.dart'; import 'package:client_reports/src/presentation/blocs/forecast/forecast_event.dart'; import 'package:client_reports/src/presentation/blocs/forecast/forecast_state.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; @@ -11,6 +12,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:intl/intl.dart'; +import '../widgets/report_detail_skeleton.dart'; + class ForecastReportPage extends StatefulWidget { const ForecastReportPage({super.key}); @@ -28,11 +31,10 @@ class _ForecastReportPageState extends State { create: (BuildContext context) => Modular.get() ..add(LoadForecastReport(startDate: _startDate, endDate: _endDate)), child: Scaffold( - backgroundColor: UiColors.bgMenu, body: BlocBuilder( builder: (BuildContext context, ForecastState state) { if (state is ForecastLoading) { - return const Center(child: CircularProgressIndicator()); + return const ReportDetailSkeleton(); } if (state is ForecastError) { @@ -86,7 +88,7 @@ class _ForecastReportPageState extends State { (ForecastWeek week) => _WeeklyBreakdownItem(week: week), ), - const SizedBox(height: 40), + const SizedBox(height: UiConstants.space24), ], ), ), @@ -124,7 +126,7 @@ class _ForecastReportPageState extends State { Row( children: [ GestureDetector( - onTap: () => Navigator.of(context).pop(), + onTap: () => Modular.to.popSafe(), child: Container( width: 40, height: 40, @@ -184,6 +186,7 @@ class _ForecastReportPageState extends State { final TranslationsClientReportsForecastReportEn t = context.t.client_reports.forecast_report; return GridView.count( crossAxisCount: 2, + padding: EdgeInsets.zero, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), mainAxisSpacing: 12, diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart index 17410784..6ba6a336 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart @@ -1,4 +1,5 @@ // ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:client_reports/src/presentation/blocs/no_show/no_show_bloc.dart'; import 'package:client_reports/src/presentation/blocs/no_show/no_show_event.dart'; @@ -10,6 +11,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:intl/intl.dart'; +import '../widgets/report_detail_skeleton.dart'; + class NoShowReportPage extends StatefulWidget { const NoShowReportPage({super.key}); @@ -27,11 +30,10 @@ class _NoShowReportPageState extends State { create: (BuildContext context) => Modular.get() ..add(LoadNoShowReport(startDate: _startDate, endDate: _endDate)), child: Scaffold( - backgroundColor: UiColors.bgMenu, body: BlocBuilder( builder: (BuildContext context, NoShowState state) { if (state is NoShowLoading) { - return const Center(child: CircularProgressIndicator()); + return const ReportDetailSkeleton(); } if (state is NoShowError) { @@ -67,7 +69,7 @@ class _NoShowReportPageState extends State { Row( children: [ GestureDetector( - onTap: () => Navigator.of(context).pop(), + onTap: () => Modular.to.popSafe(), child: Container( width: 40, height: 40, diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart index 3593b5fa..eb6f3a90 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart @@ -1,5 +1,4 @@ -// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports -import 'package:client_reports/src/presentation/blocs/performance/performance_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/performance/performance_bloc.dart'; import 'package:client_reports/src/presentation/blocs/performance/performance_event.dart'; import 'package:client_reports/src/presentation/blocs/performance/performance_state.dart'; import 'package:core_localization/core_localization.dart'; @@ -7,7 +6,10 @@ 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_domain/src/entities/reports/performance_report.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../widgets/report_detail_skeleton.dart'; class PerformanceReportPage extends StatefulWidget { const PerformanceReportPage({super.key}); @@ -26,11 +28,10 @@ class _PerformanceReportPageState extends State { create: (BuildContext context) => Modular.get() ..add(LoadPerformanceReport(startDate: _startDate, endDate: _endDate)), child: Scaffold( - backgroundColor: UiColors.bgMenu, body: BlocBuilder( builder: (BuildContext context, PerformanceState state) { if (state is PerformanceLoading) { - return const Center(child: CircularProgressIndicator()); + return const ReportDetailSkeleton(); } if (state is PerformanceError) { @@ -143,7 +144,7 @@ class _PerformanceReportPageState extends State { Row( children: [ GestureDetector( - onTap: () => Navigator.of(context).pop(), + onTap: () => Modular.to.popSafe(), child: Container( width: 40, height: 40, diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/reports_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/reports_page.dart index 10a6c620..79212649 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/reports_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/reports_page.dart @@ -1,7 +1,5 @@ -// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports -import 'package:client_reports/src/presentation/blocs/summary/reports_summary_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/summary/reports_summary_bloc.dart'; import 'package:client_reports/src/presentation/blocs/summary/reports_summary_event.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'; @@ -80,10 +78,9 @@ class _ReportsPageState extends State @override Widget build(BuildContext context) { - return BlocProvider.value( + return BlocProvider.value( value: _summaryBloc, child: Scaffold( - backgroundColor: UiColors.bgMenu, body: SingleChildScrollView( child: Column( children: [ diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart index 9b6becd6..af3265e2 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart @@ -1,5 +1,4 @@ -// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs -import 'package:client_reports/src/presentation/blocs/spend/spend_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/spend/spend_bloc.dart'; import 'package:client_reports/src/presentation/blocs/spend/spend_event.dart'; import 'package:client_reports/src/presentation/blocs/spend/spend_state.dart'; import 'package:core_localization/core_localization.dart'; @@ -9,8 +8,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:intl/intl.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; +import '../widgets/report_detail_skeleton.dart'; + class SpendReportPage extends StatefulWidget { const SpendReportPage({super.key}); @@ -35,15 +37,14 @@ class _SpendReportPageState extends State { @override Widget build(BuildContext context) { - return BlocProvider( + return BlocProvider( create: (BuildContext context) => Modular.get() ..add(LoadSpendReport(startDate: _startDate, endDate: _endDate)), child: Scaffold( - backgroundColor: UiColors.bgMenu, body: BlocBuilder( builder: (BuildContext context, SpendState state) { if (state is SpendLoading) { - return const Center(child: CircularProgressIndicator()); + return const ReportDetailSkeleton(); } if (state is SpendError) { @@ -72,7 +73,7 @@ class _SpendReportPageState extends State { Row( children: [ GestureDetector( - onTap: () => Navigator.of(context).pop(), + onTap: () => Modular.to.popSafe(), child: Container( width: 40, height: 40, diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/report_detail_skeleton.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/report_detail_skeleton.dart new file mode 100644 index 00000000..d9c26fbb --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/report_detail_skeleton.dart @@ -0,0 +1,156 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer loading skeleton for individual report detail pages. +/// +/// Shows a header area, two summary stat cards, a chart placeholder, +/// and a breakdown list. Used by spend, coverage, no-show, forecast, +/// daily ops, and performance report pages. +class ReportDetailSkeleton extends StatelessWidget { + /// Creates a [ReportDetailSkeleton]. + const ReportDetailSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + child: Column( + children: [ + // Header area (matches the blue header with back button + title) + Container( + padding: const EdgeInsets.only( + top: 60, + left: UiConstants.space5, + right: UiConstants.space5, + bottom: UiConstants.space10, + ), + color: UiColors.primary, + child: Row( + children: [ + const UiShimmerCircle(size: UiConstants.space10), + const SizedBox(width: UiConstants.space3), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerBox( + width: 140, + height: 18, + borderRadius: UiConstants.radiusSm, + ), + const SizedBox(height: UiConstants.space2), + UiShimmerBox( + width: 100, + height: 12, + borderRadius: UiConstants.radiusSm, + ), + ], + ), + ], + ), + ), + + // Content pulled up to overlap header + Transform.translate( + offset: const Offset(0, -40), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Summary stat cards row + const Row( + children: [ + Expanded(child: UiShimmerStatsCard()), + SizedBox(width: UiConstants.space3), + Expanded(child: UiShimmerStatsCard()), + ], + ), + const SizedBox(height: UiConstants.space6), + + // Chart placeholder + Container( + height: 280, + padding: const EdgeInsets.all(UiConstants.space5), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusXl, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const UiShimmerLine(width: 140, height: 14), + const SizedBox(height: UiConstants.space8), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(7, (int index) { + // Varying bar heights for visual interest + final double height = + 40.0 + (index * 17 % 120); + return UiShimmerBox( + width: 12, + height: height, + borderRadius: UiConstants.radiusSm, + ); + }), + ), + ), + const SizedBox(height: UiConstants.space3), + const UiShimmerLine(height: 10), + ], + ), + ), + const SizedBox(height: UiConstants.space6), + + // Breakdown section + Container( + padding: const EdgeInsets.all(UiConstants.space5), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusXl, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const UiShimmerLine(width: 160, height: 14), + const SizedBox(height: UiConstants.space6), + ...List.generate(3, (int index) { + return Padding( + padding: const EdgeInsets.only( + bottom: UiConstants.space5, + ), + child: Column( + children: [ + const Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + UiShimmerLine(width: 100, height: 12), + UiShimmerLine(width: 60, height: 12), + ], + ), + const SizedBox(height: UiConstants.space2), + UiShimmerBox( + width: double.infinity, + height: 6, + borderRadius: UiConstants.radiusSm, + ), + ], + ), + ); + }), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/index.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/index.dart index 58d67814..4040583c 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/index.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/index.dart @@ -1,5 +1,6 @@ export 'metric_card.dart'; export 'metrics_grid.dart'; +export 'metrics_grid_skeleton.dart'; export 'quick_reports_section.dart'; export 'report_card.dart'; export 'reports_header.dart'; diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid.dart index e90d081a..91566e93 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid.dart @@ -8,6 +8,7 @@ import 'package:intl/intl.dart'; import 'package:krow_domain/krow_domain.dart'; import 'metric_card.dart'; +import 'metrics_grid_skeleton.dart'; /// A grid of key metrics driven by the ReportsSummaryBloc. /// @@ -29,10 +30,7 @@ class MetricsGrid extends StatelessWidget { builder: (BuildContext context, ReportsSummaryState state) { // Loading or Initial State if (state is ReportsSummaryLoading || state is ReportsSummaryInitial) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 32), - child: Center(child: CircularProgressIndicator()), - ); + return const MetricsGridSkeleton(); } // Error State diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton.dart new file mode 100644 index 00000000..0bebed71 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton.dart @@ -0,0 +1 @@ +export 'metrics_grid_skeleton/index.dart'; diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton/index.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton/index.dart new file mode 100644 index 00000000..41c2aebd --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton/index.dart @@ -0,0 +1,2 @@ +export 'metric_card_skeleton.dart'; +export 'metrics_grid_skeleton.dart'; diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton/metric_card_skeleton.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton/metric_card_skeleton.dart new file mode 100644 index 00000000..61d5940d --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton/metric_card_skeleton.dart @@ -0,0 +1,45 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single metric card. +class MetricCardSkeleton extends StatelessWidget { + /// Creates a [MetricCardSkeleton]. + const MetricCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + color: UiColors.cardViewBackground, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Icon + label row + Row( + children: [ + const UiShimmerCircle(size: UiConstants.space6), + const SizedBox(width: UiConstants.space2), + const Expanded( + child: UiShimmerLine(width: 60, height: 10), + ), + ], + ), + const Spacer(), + // Value + const UiShimmerLine(width: 80, height: 22), + const SizedBox(height: UiConstants.space2), + // Badge + UiShimmerBox( + width: 60, + height: 20, + borderRadius: UiConstants.radiusSm, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton/metrics_grid_skeleton.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton/metrics_grid_skeleton.dart new file mode 100644 index 00000000..9181ec7a --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton/metrics_grid_skeleton.dart @@ -0,0 +1,31 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'metric_card_skeleton.dart'; + +/// Shimmer loading skeleton for the reports metrics grid. +/// +/// Shows a 2-column grid of 6 placeholder cards matching the [MetricsGrid] +/// loaded layout. +class MetricsGridSkeleton extends StatelessWidget { + /// Creates a [MetricsGridSkeleton]. + const MetricsGridSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: GridView.count( + padding: const EdgeInsets.symmetric(vertical: UiConstants.space6), + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: UiConstants.space3, + crossAxisSpacing: UiConstants.space3, + childAspectRatio: 1.32, + children: List.generate(6, (int index) { + return const MetricCardSkeleton(); + }), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart index d70eb8ad..d4c3b652 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart @@ -155,7 +155,7 @@ class _PhoneVerificationPageState extends State { BlocProvider.of( context, ).add(AuthResetRequested(mode: widget.mode)); - Navigator.of(context).pop(); + Modular.to.popSafe();; }, ), body: SafeArea( diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart index e7cb7754..7d254a70 100644 --- a/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart @@ -10,6 +10,7 @@ import 'package:krow_domain/krow_domain.dart'; import '../blocs/availability_bloc.dart'; import '../blocs/availability_event.dart'; import '../blocs/availability_state.dart'; +import '../widgets/availability_page_skeleton/availability_page_skeleton.dart'; class AvailabilityPage extends StatefulWidget { const AvailabilityPage({super.key}); @@ -72,7 +73,7 @@ class _AvailabilityPageState extends State { child: BlocBuilder( builder: (context, state) { if (state is AvailabilityLoading) { - return const Center(child: CircularProgressIndicator()); + return const AvailabilityPageSkeleton(); } else if (state is AvailabilityLoaded) { return Stack( children: [ diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton.dart new file mode 100644 index 00000000..59e45024 --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton.dart @@ -0,0 +1 @@ +export 'availability_page_skeleton/index.dart'; diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/availability_page_skeleton.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/availability_page_skeleton.dart new file mode 100644 index 00000000..b4b0bc2b --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/availability_page_skeleton.dart @@ -0,0 +1,36 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'day_availability_skeleton.dart'; +import 'info_card_skeleton.dart'; +import 'quick_set_skeleton.dart'; +import 'week_navigation_skeleton.dart'; + +/// Shimmer loading skeleton for the availability page. +/// +/// Mimics the loaded layout: quick-set buttons, week navigation calendar, +/// selected day detail with time-slot rows, and an info card. +class AvailabilityPageSkeleton extends StatelessWidget { + /// Creates an [AvailabilityPageSkeleton]. + const AvailabilityPageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), + child: Column( + spacing: UiConstants.space6, + children: const [ + QuickSetSkeleton(), + WeekNavigationSkeleton(), + DayAvailabilitySkeleton(), + InfoCardSkeleton(), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/day_availability_skeleton.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/day_availability_skeleton.dart new file mode 100644 index 00000000..cdf984f5 --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/day_availability_skeleton.dart @@ -0,0 +1,88 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the selected day detail card (header + time slot rows). +class DayAvailabilitySkeleton extends StatelessWidget { + /// Creates a [DayAvailabilitySkeleton]. + const DayAvailabilitySkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space5), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + ), + child: Column( + children: [ + // Header: date text + toggle placeholder + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + UiShimmerLine(width: 160, height: 16), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 80, height: 12), + ], + ), + UiShimmerBox( + width: 48, + height: 28, + borderRadius: UiConstants.radiusFull, + ), + ], + ), + const SizedBox(height: UiConstants.space4), + // 3 time-slot rows (morning, afternoon, evening) + ..._buildSlotPlaceholders(), + ], + ), + ); + } + + /// Generates 3 time-slot shimmer rows. + List _buildSlotPlaceholders() { + return List.generate(3, (index) { + return Padding( + padding: EdgeInsets.only( + bottom: index < 2 ? UiConstants.space3 : 0, + ), + child: Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + ), + child: Row( + children: [ + // Icon placeholder + UiShimmerBox( + width: 40, + height: 40, + borderRadius: + UiConstants.radiusLg, + ), + const SizedBox(width: UiConstants.space3), + // Text lines + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 80, height: 14), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 120, height: 12), + ], + ), + ), + // Checkbox circle + const UiShimmerCircle(size: 24), + ], + ), + ), + ); + }); + } +} diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/index.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/index.dart new file mode 100644 index 00000000..505afb28 --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/index.dart @@ -0,0 +1,5 @@ +export 'availability_page_skeleton.dart'; +export 'day_availability_skeleton.dart'; +export 'info_card_skeleton.dart'; +export 'quick_set_skeleton.dart'; +export 'week_navigation_skeleton.dart'; diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/info_card_skeleton.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/info_card_skeleton.dart new file mode 100644 index 00000000..2c3ad6e0 --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/info_card_skeleton.dart @@ -0,0 +1,36 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the info card at the bottom (icon + two text lines). +class InfoCardSkeleton extends StatelessWidget { + /// Creates an [InfoCardSkeleton]. + const InfoCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const UiShimmerCircle(size: 20), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + UiShimmerLine(width: 140, height: 14), + SizedBox(height: UiConstants.space1), + UiShimmerLine(height: 12), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/quick_set_skeleton.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/quick_set_skeleton.dart new file mode 100644 index 00000000..6e31c4af --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/quick_set_skeleton.dart @@ -0,0 +1,45 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the quick-set section (title + 4 action buttons). +class QuickSetSkeleton extends StatelessWidget { + /// Creates a [QuickSetSkeleton]. + const QuickSetSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title line + const UiShimmerLine(width: 100, height: 14), + const SizedBox(height: UiConstants.space3), + // Row of 4 button placeholders + Row( + children: List.generate(4, (index) { + return Expanded( + child: Padding( + padding: EdgeInsets.only( + left: index == 0 ? 0 : UiConstants.space1, + right: index == 3 ? 0 : UiConstants.space1, + ), + child: UiShimmerBox( + width: double.infinity, + height: 32, + borderRadius: UiConstants.radiusLg, + ), + ), + ); + }), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/week_navigation_skeleton.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/week_navigation_skeleton.dart new file mode 100644 index 00000000..cfede807 --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/week_navigation_skeleton.dart @@ -0,0 +1,51 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the week navigation card (month header + 7 day cells). +class WeekNavigationSkeleton extends StatelessWidget { + /// Creates a [WeekNavigationSkeleton]. + const WeekNavigationSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + ), + child: Column( + children: [ + // Navigation header: left arrow, month label, right arrow + Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const UiShimmerCircle(size: 32), + UiShimmerLine(width: 140, height: 16), + const UiShimmerCircle(size: 32), + ], + ), + ), + // 7 day cells + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(7, (_) { + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space1), + child: UiShimmerBox( + width: double.infinity, + height: 64, + borderRadius: UiConstants.radiusLg, + ), + ), + ); + }), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/availability/lib/src/staff_availability_module.dart b/apps/mobile/packages/features/staff/availability/lib/src/staff_availability_module.dart index 7d596b28..7c7b7a74 100644 --- a/apps/mobile/packages/features/staff/availability/lib/src/staff_availability_module.dart +++ b/apps/mobile/packages/features/staff/availability/lib/src/staff_availability_module.dart @@ -17,12 +17,12 @@ class StaffAvailabilityModule extends Module { @override void binds(Injector i) { // Repository - i.add(AvailabilityRepositoryImpl.new); + i.addLazySingleton(AvailabilityRepositoryImpl.new); // UseCases - i.add(GetWeeklyAvailabilityUseCase.new); - i.add(UpdateDayAvailabilityUseCase.new); - i.add(ApplyQuickSetUseCase.new); + i.addLazySingleton(GetWeeklyAvailabilityUseCase.new); + i.addLazySingleton(UpdateDayAvailabilityUseCase.new); + i.addLazySingleton(ApplyQuickSetUseCase.new); // BLoC i.add(AvailabilityBloc.new); diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart index 3f6fbadc..76636878 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart @@ -10,6 +10,7 @@ import 'package:krow_domain/krow_domain.dart'; import '../bloc/clock_in_bloc.dart'; import '../bloc/clock_in_event.dart'; import '../bloc/clock_in_state.dart'; +import '../widgets/clock_in_page_skeleton/clock_in_page_skeleton.dart'; import '../widgets/commute_tracker.dart'; import '../widgets/date_selector.dart'; import '../widgets/lunch_break_modal.dart'; @@ -52,8 +53,9 @@ class _ClockInPageState extends State { builder: (BuildContext context, ClockInState state) { if (state.status == ClockInStatus.loading && state.todayShifts.isEmpty) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), + return Scaffold( + appBar: UiAppBar(title: i18n.title, showBackButton: false), + body: const SafeArea(child: ClockInPageSkeleton()), ); } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/activity_header_skeleton.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/activity_header_skeleton.dart new file mode 100644 index 00000000..4b392c5f --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/activity_header_skeleton.dart @@ -0,0 +1,16 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the "Your Activity" section header text. +class ActivityHeaderSkeleton extends StatelessWidget { + /// Creates a shimmer line matching the activity header. + const ActivityHeaderSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Align( + alignment: Alignment.centerLeft, + child: UiShimmerLine(width: 120, height: 18), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/clock_in_page_skeleton.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/clock_in_page_skeleton.dart new file mode 100644 index 00000000..b4c0aade --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/clock_in_page_skeleton.dart @@ -0,0 +1,52 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'activity_header_skeleton.dart'; +import 'date_selector_skeleton.dart'; +import 'shift_card_skeleton.dart'; +import 'swipe_action_skeleton.dart'; + +/// Full-page shimmer skeleton shown while clock-in data loads. +/// +/// Mirrors the loaded [ClockInPage] layout: date selector, activity header, +/// two shift cards, and the swipe-to-check-in bar. +class ClockInPageSkeleton extends StatelessWidget { + /// Creates the clock-in page shimmer skeleton. + const ClockInPageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + padding: const EdgeInsets.only( + bottom: UiConstants.space24, + top: UiConstants.space6, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + // Date selector row + DateSelectorSkeleton(), + SizedBox(height: UiConstants.space5), + + // "Your Activity" header + ActivityHeaderSkeleton(), + SizedBox(height: UiConstants.space4), + + // Shift cards (show two placeholders) + ShiftCardSkeleton(), + ShiftCardSkeleton(), + + // Swipe action bar + SwipeActionSkeleton(), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/date_selector_skeleton.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/date_selector_skeleton.dart new file mode 100644 index 00000000..e84b7c7c --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/date_selector_skeleton.dart @@ -0,0 +1,32 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the [DateSelector] row of 7 day chips. +class DateSelectorSkeleton extends StatelessWidget { + /// Creates a shimmer placeholder matching the date selector layout. + const DateSelectorSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 80, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(7, (int index) { + return Expanded( + child: Container( + margin: const EdgeInsets.symmetric( + horizontal: UiConstants.space1, + ), + child: UiShimmerBox( + width: double.infinity, + height: 80, + borderRadius: UiConstants.radiusLg, + ), + ), + ); + }), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/shift_card_skeleton.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/shift_card_skeleton.dart new file mode 100644 index 00000000..9665d288 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/shift_card_skeleton.dart @@ -0,0 +1,52 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single shift info card. +/// +/// Mirrors the two-column layout: left side has badge, title, and subtitle +/// lines; right side has time range and rate lines. +class ShiftCardSkeleton extends StatelessWidget { + /// Creates a shimmer placeholder for one shift card. + const ShiftCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space3), + margin: const EdgeInsets.only(bottom: UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + ), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Left column: badge + title + subtitle + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 80, height: 10), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 160, height: 14), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 200, height: 12), + ], + ), + ), + SizedBox(width: UiConstants.space3), + // Right column: time + rate + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + UiShimmerLine(width: 100, height: 12), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 60, height: 12), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/swipe_action_skeleton.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/swipe_action_skeleton.dart new file mode 100644 index 00000000..4218186b --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/swipe_action_skeleton.dart @@ -0,0 +1,17 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the swipe-to-check-in action area. +class SwipeActionSkeleton extends StatelessWidget { + /// Creates a shimmer placeholder matching the swipe bar height. + const SwipeActionSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmerBox( + width: double.infinity, + height: 60, + borderRadius: UiConstants.radiusLg, + ); + } +} 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 5045548b..1e204eb8 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 @@ -8,7 +8,9 @@ import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart'; import 'package:staff_home/src/presentation/widgets/home_page/benefits_section.dart'; import 'package:staff_home/src/presentation/widgets/home_page/full_width_divider.dart'; import 'package:staff_home/src/presentation/widgets/home_page/home_header.dart'; +import 'package:staff_home/src/presentation/widgets/home_page/staff_home_header_skeleton.dart'; import 'package:staff_home/src/presentation/widgets/home_page/placeholder_banner.dart'; +import 'package:staff_home/src/presentation/widgets/home_page/home_page_skeleton.dart'; import 'package:staff_home/src/presentation/widgets/home_page/quick_actions_section.dart'; import 'package:staff_home/src/presentation/widgets/home_page/recommended_shifts_section.dart'; import 'package:staff_home/src/presentation/widgets/home_page/todays_shifts_section.dart'; @@ -47,8 +49,13 @@ class WorkerHomePage extends StatelessWidget { children: [ BlocBuilder( buildWhen: (previous, current) => - previous.staffName != current.staffName, + previous.staffName != current.staffName || + previous.status != current.status, builder: (context, state) { + if (state.status == HomeStatus.initial || + state.status == HomeStatus.loading) { + return const StaffHomeHeaderSkeleton(); + } return HomeHeader(userName: state.staffName); }, ), @@ -59,8 +66,14 @@ class WorkerHomePage extends StatelessWidget { ), child: BlocBuilder( buildWhen: (previous, current) => + previous.status != current.status || previous.isProfileComplete != current.isProfileComplete, builder: (context, state) { + if (state.status == HomeStatus.loading || + state.status == HomeStatus.initial) { + return const HomePageSkeleton(); + } + if (!state.isProfileComplete) { return SizedBox( height: MediaQuery.of(context).size.height - 300, diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/full_width_divider.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/full_width_divider.dart index 3ffaf542..9712bfac 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/full_width_divider.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/full_width_divider.dart @@ -11,16 +11,16 @@ class FullWidthDivider extends StatelessWidget { @override Widget build(BuildContext context) { - final screenWidth = MediaQuery.of(context).size.width; + //final screenWidth = MediaQuery.of(context).size.width; return Column( children: [ const SizedBox(height: UiConstants.space10), - Transform.translate( - offset: const Offset(-UiConstants.space4, 0), - child: SizedBox(width: screenWidth, child: const Divider()), - ), - const SizedBox(height: UiConstants.space10), + // Transform.translate( + // offset: const Offset(-UiConstants.space4, 0), + // child: SizedBox(width: screenWidth, child: const Divider()), + // ), + // const SizedBox(height: UiConstants.space10), ], ); } diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton.dart new file mode 100644 index 00000000..652a6c58 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton.dart @@ -0,0 +1 @@ +export 'home_page_skeleton/index.dart'; diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/home_page_skeleton.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/home_page_skeleton.dart new file mode 100644 index 00000000..2892b948 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/home_page_skeleton.dart @@ -0,0 +1,66 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'quick_actions_skeleton.dart'; +import 'recommended_section_skeleton.dart'; +import 'shift_section_skeleton.dart'; +import 'skeleton_divider.dart'; + +/// Shimmer loading skeleton for the staff home page. +/// +/// Mimics the loaded layout with quick actions, today's shifts, tomorrow's +/// shifts, recommended shifts, and benefits sections. Displayed while +/// [HomeCubit] is fetching initial data. +class HomePageSkeleton extends StatelessWidget { + /// Creates a [HomePageSkeleton]. + const HomePageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Quick actions row (3 circular icons + labels) + const QuickActionsSkeleton(), + + const SkeletonDivider(), + + // Today's Shifts section + const ShiftSectionSkeleton(), + + const SkeletonDivider(), + + // Tomorrow's Shifts section + const ShiftSectionSkeleton(), + + const SkeletonDivider(), + + // Recommended Shifts (horizontal cards) + const RecommendedSectionSkeleton(), + + const SkeletonDivider(), + + // Benefits section + Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space3, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const UiShimmerSectionHeader(), + const SizedBox(height: UiConstants.space3), + UiShimmerList( + itemCount: 2, + itemBuilder: (index) => const UiShimmerListItem(), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/index.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/index.dart new file mode 100644 index 00000000..bb80e1c9 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/index.dart @@ -0,0 +1,6 @@ +export 'home_page_skeleton.dart'; +export 'quick_actions_skeleton.dart'; +export 'recommended_section_skeleton.dart'; +export 'shift_card_skeleton.dart'; +export 'shift_section_skeleton.dart'; +export 'skeleton_divider.dart'; diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/quick_actions_skeleton.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/quick_actions_skeleton.dart new file mode 100644 index 00000000..b7dc048c --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/quick_actions_skeleton.dart @@ -0,0 +1,32 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Skeleton for the quick actions row (3 circular placeholders with labels). +class QuickActionsSkeleton extends StatelessWidget { + /// Creates a [QuickActionsSkeleton]. + const QuickActionsSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space3, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(3, (index) { + return const Expanded( + child: Column( + children: [ + UiShimmerCircle(size: 48), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 60, height: 12), + ], + ), + ); + }), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/recommended_section_skeleton.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/recommended_section_skeleton.dart new file mode 100644 index 00000000..15cd2ffe --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/recommended_section_skeleton.dart @@ -0,0 +1,44 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Skeleton for the recommended shifts horizontal scroll section. +class RecommendedSectionSkeleton extends StatelessWidget { + /// Creates a [RecommendedSectionSkeleton]. + const RecommendedSectionSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: UiConstants.space3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: UiConstants.space4), + child: UiShimmerSectionHeader(), + ), + const SizedBox(height: UiConstants.space3), + SizedBox( + height: 120, + child: ListView.builder( + scrollDirection: Axis.horizontal, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + ), + itemCount: 3, + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.only(right: UiConstants.space3), + child: UiShimmerBox( + width: 200, + height: 120, + borderRadius: UiConstants.radiusLg, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/shift_card_skeleton.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/shift_card_skeleton.dart new file mode 100644 index 00000000..450aea7d --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/shift_card_skeleton.dart @@ -0,0 +1,37 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Skeleton for a single compact shift card on the home page. +class ShiftCardSkeleton extends StatelessWidget { + /// Creates a [ShiftCardSkeleton]. + const ShiftCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + ), + child: const Row( + children: [ + UiShimmerBox(width: 48, height: 48), + SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 160, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 120, height: 12), + ], + ), + ), + SizedBox(width: UiConstants.space3), + UiShimmerBox(width: 56, height: 24), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/shift_section_skeleton.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/shift_section_skeleton.dart new file mode 100644 index 00000000..f8ffc72a --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/shift_section_skeleton.dart @@ -0,0 +1,31 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'shift_card_skeleton.dart'; + +/// Skeleton for a shift section (section header + 2 shift card placeholders). +class ShiftSectionSkeleton extends StatelessWidget { + /// Creates a [ShiftSectionSkeleton]. + const ShiftSectionSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space3, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const UiShimmerSectionHeader(), + const SizedBox(height: UiConstants.space3), + UiShimmerList( + itemCount: 2, + itemBuilder: (index) => const ShiftCardSkeleton(), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/skeleton_divider.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/skeleton_divider.dart new file mode 100644 index 00000000..51f0566c --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/skeleton_divider.dart @@ -0,0 +1,13 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A thin full-width divider placeholder matching the home page layout. +class SkeletonDivider extends StatelessWidget { + /// Creates a [SkeletonDivider]. + const SkeletonDivider({super.key}); + + @override + Widget build(BuildContext context) { + return const Divider(height: 1, thickness: 0.5, color: UiColors.border); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/staff_home_header_skeleton.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/staff_home_header_skeleton.dart new file mode 100644 index 00000000..e3e7d7e1 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/staff_home_header_skeleton.dart @@ -0,0 +1,38 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the staff home header during loading. +/// +/// Mimics the avatar circle, welcome text, and user name layout. +class StaffHomeHeaderSkeleton extends StatelessWidget { + /// Creates a [StaffHomeHeaderSkeleton]. + const StaffHomeHeaderSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: Padding( + padding: const EdgeInsets.fromLTRB( + UiConstants.space4, + UiConstants.space4, + UiConstants.space4, + UiConstants.space3, + ), + child: Row( + spacing: UiConstants.space3, + children: const [ + UiShimmerCircle(size: UiConstants.space12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 80, height: 12), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 120, height: 16), + ], + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/todays_shifts_section.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/todays_shifts_section.dart index 764da501..adad147a 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/todays_shifts_section.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/todays_shifts_section.dart @@ -35,15 +35,7 @@ class TodaysShiftsSection extends StatelessWidget { ) : null, child: state.status == HomeStatus.loading - ? const Center( - child: SizedBox( - height: UiConstants.space10, - width: UiConstants.space10, - child: CircularProgressIndicator( - color: UiColors.primary, - ), - ), - ) + ? const _ShiftsSectionSkeleton() : shifts.isEmpty ? EmptyStateWidget( message: emptyI18n.no_shifts_today, @@ -66,3 +58,40 @@ class TodaysShiftsSection extends StatelessWidget { ); } } + +/// Inline shimmer skeleton for the shifts section loading state. +class _ShiftsSectionSkeleton extends StatelessWidget { + const _ShiftsSectionSkeleton(); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: UiShimmerList( + itemCount: 2, + itemBuilder: (index) => Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + ), + child: const Row( + children: [ + UiShimmerBox(width: 48, height: 48), + SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 160, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 120, height: 12), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart b/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart index 0b319174..921a304a 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart @@ -33,7 +33,7 @@ class StaffHomeModule extends Module { ); // Presentation layer - Cubits - i.addSingleton( + i.addLazySingleton( () => HomeCubit( repository: i.get(), getProfileCompletion: i.get(), diff --git a/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart b/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart index 726a84b1..3c701b36 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart @@ -67,6 +67,16 @@ class PaymentsRepositoryImpl .execute(); return response.data.recentPayments.map((dc.ListRecentPaymentsByStaffIdRecentPayments payment) { + // Extract shift details from nested application structure + final String? shiftTitle = payment.application.shiftRole.shift.title; + final String? locationAddress = payment.application.shiftRole.shift.locationAddress; + final double? hoursWorked = payment.application.shiftRole.hours; + final double? hourlyRate = payment.application.shiftRole.role.costPerHour; + // Extract hub details from order + final String? locationHub = payment.invoice.order.teamHub.hubName; + final String? hubAddress = payment.invoice.order.teamHub.address; + final String? shiftLocation = locationAddress ?? hubAddress; + return StaffPayment( id: payment.id, staffId: payment.staffId, @@ -74,6 +84,12 @@ class PaymentsRepositoryImpl amount: payment.invoice.amount, status: PaymentAdapter.toPaymentStatus(payment.status?.stringValue ?? 'UNKNOWN'), paidAt: _service.toDateTime(payment.invoice.issueDate), + shiftTitle: shiftTitle, + shiftLocation: locationHub, + locationAddress: shiftLocation, + hoursWorked: hoursWorked, + hourlyRate: hourlyRate, + workedTime: payment.workedTime, ); }).toList(); }); diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart index b1ce9e4e..1420c110 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart @@ -1,5 +1,4 @@ import 'package:design_system/design_system.dart'; -import 'package:krow_core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; @@ -9,8 +8,8 @@ import 'package:core_localization/core_localization.dart'; import '../blocs/payments/payments_bloc.dart'; import '../blocs/payments/payments_event.dart'; import '../blocs/payments/payments_state.dart'; +import '../widgets/payments_page_skeleton.dart'; import '../widgets/payment_stats_card.dart'; -import '../widgets/pending_pay_card.dart'; import '../widgets/payment_history_item.dart'; import '../widgets/earnings_graph.dart'; @@ -43,7 +42,7 @@ class _PaymentsPageState extends State { }, builder: (BuildContext context, PaymentsState state) { if (state is PaymentsLoading) { - return const Center(child: CircularProgressIndicator()); + return const PaymentsPageSkeleton(); } else if (state is PaymentsError) { return Center( @@ -172,17 +171,7 @@ class _PaymentsPageState extends State { ), ], ), - const SizedBox(height: UiConstants.space4), - - // Pending Pay - if (state.summary.pendingEarnings > 0) - PendingPayCard( - amount: state.summary.pendingEarnings, - onCashOut: () { - Modular.to.pushNamed('${StaffPaths.payments}early-pay'); - }, - ), - const SizedBox(height: UiConstants.space6), + const SizedBox(height: UiConstants.space8), // Recent Payments if (state.history.isNotEmpty) @@ -191,7 +180,7 @@ class _PaymentsPageState extends State { children: [ Text( "Recent Payments", - style: UiTypography.body2m.textPrimary, + style: UiTypography.body1b, ), const SizedBox(height: UiConstants.space3), Column( @@ -201,16 +190,16 @@ class _PaymentsPageState extends State { bottom: UiConstants.space2), child: PaymentHistoryItem( amount: payment.amount, - title: "Shift Payment", - location: "Varies", - address: "Payment ID: ${payment.id}", + title: payment.shiftTitle ?? "Shift Payment", + location: payment.shiftLocation ?? "Varies", + address: payment.locationAddress ?? payment.id, date: payment.paidAt != null ? DateFormat('E, MMM d') .format(payment.paidAt!) : 'Pending', - workedTime: "Completed", - hours: 0, - rate: 0.0, + workedTime: payment.workedTime ?? "Completed", + hours: (payment.hoursWorked ?? 0).toInt(), + rate: payment.hourlyRate ?? 0.0, status: payment.status.name.toUpperCase(), ), ); diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payment_history_item.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payment_history_item.dart index e068caee..44fe3304 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payment_history_item.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payment_history_item.dart @@ -32,13 +32,7 @@ class PaymentHistoryItem extends StatelessWidget { decoration: BoxDecoration( color: UiColors.white, borderRadius: BorderRadius.circular(UiConstants.radiusBase), - boxShadow: [ - BoxShadow( - color: UiColors.black.withValues(alpha: 0.05), - blurRadius: 2, - offset: const Offset(0, 1), - ), - ], + border: Border.all(color: UiColors.border, width: 0.5), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -77,7 +71,7 @@ class PaymentHistoryItem extends StatelessWidget { borderRadius: BorderRadius.circular(UiConstants.radiusBase), ), child: const Icon( - UiIcons.chart, + UiIcons.dollar, color: UiColors.mutedForeground, size: 24, ), @@ -98,7 +92,7 @@ class PaymentHistoryItem extends StatelessWidget { children: [ Text( title, - style: UiTypography.body2b.textPrimary, + style: UiTypography.body2m, ), Text( location, @@ -112,7 +106,7 @@ class PaymentHistoryItem extends StatelessWidget { children: [ Text( "\$${amount.toStringAsFixed(0)}", - style: UiTypography.headline4m.textPrimary, + style: UiTypography.headline4b, ), Text( "\$${rate.toStringAsFixed(0)}/hr · ${hours}h", diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton.dart new file mode 100644 index 00000000..f6d4c461 --- /dev/null +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton.dart @@ -0,0 +1 @@ +export 'payments_page_skeleton/index.dart'; diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton/index.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton/index.dart new file mode 100644 index 00000000..ec96faf5 --- /dev/null +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton/index.dart @@ -0,0 +1,2 @@ +export 'payment_item_skeleton.dart'; +export 'payments_page_skeleton.dart'; diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton/payment_item_skeleton.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton/payment_item_skeleton.dart new file mode 100644 index 00000000..2d24c1ae --- /dev/null +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton/payment_item_skeleton.dart @@ -0,0 +1,40 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Skeleton for a single payment history item. +/// +/// Matches the [PaymentHistoryItem] layout with a leading icon, title/subtitle +/// lines, and trailing amount text. +class PaymentItemSkeleton extends StatelessWidget { + /// Creates a [PaymentItemSkeleton]. + const PaymentItemSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + ), + child: const Row( + children: [ + UiShimmerCircle(size: 40), + SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 140, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 100, height: 12), + ], + ), + ), + SizedBox(width: UiConstants.space3), + UiShimmerLine(width: 60, height: 16), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton/payments_page_skeleton.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton/payments_page_skeleton.dart new file mode 100644 index 00000000..45de7a7a --- /dev/null +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton/payments_page_skeleton.dart @@ -0,0 +1,113 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'payment_item_skeleton.dart'; + +/// Shimmer loading skeleton for the payments page. +/// +/// Mimics the loaded layout: a gradient header with balance and period tabs, +/// an earnings graph placeholder, stat cards, and a recent payments list. +class PaymentsPageSkeleton extends StatelessWidget { + /// Creates a [PaymentsPageSkeleton]. + const PaymentsPageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + child: Column( + children: [ + // Header section with gradient + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + UiColors.primary, + UiColors.primary.withValues(alpha: 0.8), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + padding: EdgeInsets.fromLTRB( + UiConstants.space5, + MediaQuery.of(context).padding.top + UiConstants.space6, + UiConstants.space5, + UiConstants.space8, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title placeholder + const UiShimmerLine(width: 120, height: 24), + const SizedBox(height: UiConstants.space6), + + // Balance center + const Center( + child: Column( + children: [ + UiShimmerLine(width: 100, height: 14), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 160, height: 36), + ], + ), + ), + const SizedBox(height: UiConstants.space4), + + // Period tabs placeholder + UiShimmerBox( + width: double.infinity, + height: 40, + borderRadius: UiConstants.radiusMd, + ), + ], + ), + ), + + // Main content offset upwards + Transform.translate( + offset: const Offset(0, -UiConstants.space4), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Earnings graph placeholder + UiShimmerBox( + width: double.infinity, + height: 180, + borderRadius: UiConstants.radiusLg, + ), + const SizedBox(height: UiConstants.space6), + + // Quick stats row + Row( + children: [ + Expanded(child: UiShimmerStatsCard()), + const SizedBox(width: UiConstants.space3), + Expanded(child: UiShimmerStatsCard()), + ], + ), + const SizedBox(height: UiConstants.space8), + + // Recent Payments header + const UiShimmerSectionHeader(), + const SizedBox(height: UiConstants.space3), + + // Payment history items + UiShimmerList( + itemCount: 4, + itemBuilder: (index) => const PaymentItemSkeleton(), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} 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 8bec14f2..7f54d16b 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 @@ -10,6 +10,7 @@ import '../blocs/profile_cubit.dart'; import '../blocs/profile_state.dart'; import '../widgets/logout_button.dart'; import '../widgets/header/profile_header.dart'; +import '../widgets/profile_page_skeleton/profile_page_skeleton.dart'; import '../widgets/reliability_score_bar.dart'; import '../widgets/reliability_stats_card.dart'; import '../widgets/sections/index.dart'; @@ -63,9 +64,9 @@ class StaffProfilePage extends StatelessWidget { } }, builder: (BuildContext context, ProfileState state) { - // Show loading spinner if status is loading + // Show shimmer skeleton while profile data loads if (state.status == ProfileStatus.loading) { - return const Center(child: CircularProgressIndicator()); + return const ProfilePageSkeleton(); } if (state.status == ProfileStatus.error) { @@ -87,7 +88,7 @@ class StaffProfilePage extends StatelessWidget { final Staff? profile = state.profile; if (profile == null) { - return const Center(child: CircularProgressIndicator()); + return const ProfilePageSkeleton(); } return SingleChildScrollView( diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/index.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/index.dart new file mode 100644 index 00000000..5996ff84 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/index.dart @@ -0,0 +1,5 @@ +export 'menu_section_skeleton.dart'; +export 'profile_header_skeleton.dart'; +export 'profile_page_skeleton.dart'; +export 'reliability_score_skeleton.dart'; +export 'reliability_stats_skeleton.dart'; diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/menu_section_skeleton.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/menu_section_skeleton.dart new file mode 100644 index 00000000..ff95bbbc --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/menu_section_skeleton.dart @@ -0,0 +1,87 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a profile menu section. +/// +/// Mirrors the section layout: a section title line followed by a grid of +/// square menu item placeholders. Reused for onboarding, compliance, finance, +/// and support sections. +class MenuSectionSkeleton extends StatelessWidget { + /// Creates a [MenuSectionSkeleton]. + const MenuSectionSkeleton({ + super.key, + this.itemCount = 4, + this.crossAxisCount = 3, + }); + + /// Number of menu item placeholders to display. + final int itemCount; + + /// Number of columns in the grid. + final int crossAxisCount; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Section title placeholder + Padding( + padding: const EdgeInsets.only(left: UiConstants.space1), + child: const UiShimmerLine(width: 100, height: 12), + ), + const SizedBox(height: UiConstants.space3), + // Menu items grid + LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + const double spacing = UiConstants.space3; + final double totalWidth = constraints.maxWidth; + final double totalSpacingWidth = spacing * (crossAxisCount - 1); + final double itemWidth = + (totalWidth - totalSpacingWidth) / crossAxisCount; + + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: List.generate(itemCount, (int index) { + return SizedBox( + width: itemWidth, + child: const _MenuItemSkeleton(), + ); + }), + ); + }, + ), + ], + ); + } +} + +/// Single menu item shimmer: a bordered square with an icon circle and label +/// line. +class _MenuItemSkeleton extends StatelessWidget { + const _MenuItemSkeleton(); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: UiColors.bgPopup, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + ), + padding: const EdgeInsets.all(UiConstants.space2), + child: const AspectRatio( + aspectRatio: 1.0, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + UiShimmerBox(width: 36, height: 36), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 48, height: 10), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/profile_header_skeleton.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/profile_header_skeleton.dart new file mode 100644 index 00000000..60ee0ac0 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/profile_header_skeleton.dart @@ -0,0 +1,45 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the profile header section. +/// +/// Mirrors [ProfileHeader] layout: circle avatar, name line, and level badge +/// on the primary-colored background with rounded bottom corners. +class ProfileHeaderSkeleton extends StatelessWidget { + /// Creates a [ProfileHeaderSkeleton]. + const ProfileHeaderSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space5, + UiConstants.space5, + UiConstants.space16, + ), + decoration: const BoxDecoration( + color: UiColors.primary, + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(UiConstants.space6), + ), + ), + child: SafeArea( + bottom: false, + child: Column( + children: [ + // Avatar placeholder + const UiShimmerCircle(size: 112), + const SizedBox(height: UiConstants.space4), + // Name placeholder + const UiShimmerLine(width: 160, height: 20), + const SizedBox(height: UiConstants.space2), + // Level badge placeholder + const UiShimmerBox(width: 100, height: 24), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/profile_page_skeleton.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/profile_page_skeleton.dart new file mode 100644 index 00000000..162a61e6 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/profile_page_skeleton.dart @@ -0,0 +1,62 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'menu_section_skeleton.dart'; +import 'profile_header_skeleton.dart'; +import 'reliability_score_skeleton.dart'; +import 'reliability_stats_skeleton.dart'; + +/// Full-page shimmer skeleton for [StaffProfilePage]. +/// +/// Mimics the loaded profile layout: header, reliability stats, score bar, +/// and four menu sections. Displayed while [ProfileCubit] fetches data. +class ProfilePageSkeleton extends StatelessWidget { + /// Creates a [ProfilePageSkeleton]. + const ProfilePageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + child: Column( + children: [ + // Header with avatar, name, and badge + const ProfileHeaderSkeleton(), + + // Content offset to overlap the header bottom radius + Transform.translate( + offset: const Offset(0, -UiConstants.space6), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), + child: Column( + spacing: UiConstants.space6, + children: const [ + // Reliability stats row (5 items) + ReliabilityStatsSkeleton(), + + // Reliability score bar + ReliabilityScoreSkeleton(), + + // Onboarding section (4 items, 3 columns) + MenuSectionSkeleton(itemCount: 4, crossAxisCount: 3), + + // Compliance section (3 items, 3 columns) + MenuSectionSkeleton(itemCount: 3, crossAxisCount: 3), + + // Finance section (3 items, 3 columns) + MenuSectionSkeleton(itemCount: 3, crossAxisCount: 3), + + // Support section (2 items, 3 columns) + MenuSectionSkeleton(itemCount: 2, crossAxisCount: 3), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/reliability_score_skeleton.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/reliability_score_skeleton.dart new file mode 100644 index 00000000..8bc6898a --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/reliability_score_skeleton.dart @@ -0,0 +1,45 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the reliability score bar section. +/// +/// Mirrors [ReliabilityScoreBar] layout: a tinted container with a title line, +/// percentage line, progress bar placeholder, and description line. +class ReliabilityScoreSkeleton extends StatelessWidget { + /// Creates a [ReliabilityScoreSkeleton]. + const ReliabilityScoreSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.primary.withValues(alpha: 0.1), + borderRadius: UiConstants.radiusLg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title row with label and percentage + const Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + UiShimmerLine(width: 120, height: 14), + UiShimmerLine(width: 40, height: 18), + ], + ), + const SizedBox(height: UiConstants.space2), + // Progress bar placeholder + UiShimmerBox( + width: double.infinity, + height: 8, + borderRadius: UiConstants.radiusSm, + ), + const SizedBox(height: UiConstants.space2), + // Description line + const UiShimmerLine(width: 200, height: 10), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/reliability_stats_skeleton.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/reliability_stats_skeleton.dart new file mode 100644 index 00000000..a8d40bb4 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/reliability_stats_skeleton.dart @@ -0,0 +1,54 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the reliability stats card. +/// +/// Mirrors [ReliabilityStatsCard] layout: a bordered card containing five +/// evenly-spaced stat columns, each with an icon circle, value line, and +/// label line. +class ReliabilityStatsSkeleton extends StatelessWidget { + /// Creates a [ReliabilityStatsSkeleton]. + const ReliabilityStatsSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.bgPopup, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _StatItemSkeleton(), + _StatItemSkeleton(), + _StatItemSkeleton(), + _StatItemSkeleton(), + _StatItemSkeleton(), + ], + ), + ); + } +} + +/// Single stat column shimmer: icon circle, value line, label line. +class _StatItemSkeleton extends StatelessWidget { + const _StatItemSkeleton(); + + @override + Widget build(BuildContext context) { + return const Expanded( + child: Column( + children: [ + UiShimmerBox(width: UiConstants.space10, height: UiConstants.space10), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 28, height: 14), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 36, height: 10), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart index f9b720cb..c49c8ecf 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart @@ -68,7 +68,7 @@ class StaffProfileModule extends Module { // Presentation layer - Cubit as singleton to avoid recreation // BlocProvider will use this same instance, preventing state emission after close - i.addSingleton( + i.addLazySingleton( () => ProfileCubit( i.get(), i.get(), diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificates_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificates_page.dart index 21e2c4c7..c393f0e0 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificates_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificates_page.dart @@ -11,6 +11,7 @@ import '../blocs/certificates/certificates_state.dart'; import '../widgets/add_certificate_card.dart'; import '../widgets/certificate_card.dart'; import '../widgets/certificates_header.dart'; +import '../widgets/certificates_skeleton/certificates_skeleton.dart'; /// Page for viewing and managing staff certificates. /// @@ -28,9 +29,7 @@ class CertificatesPage extends StatelessWidget { builder: (BuildContext context, CertificatesState state) { if (state.status == CertificatesStatus.loading || state.status == CertificatesStatus.initial) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); + return const Scaffold(body: CertificatesSkeleton()); } if (state.status == CertificatesStatus.failure) { diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_skeleton/certificate_card_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_skeleton/certificate_card_skeleton.dart new file mode 100644 index 00000000..55b05acb --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_skeleton/certificate_card_skeleton.dart @@ -0,0 +1,38 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single certificate card. +class CertificateCardSkeleton extends StatelessWidget { + /// Creates a [CertificateCardSkeleton]. + const CertificateCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + margin: const EdgeInsets.only(bottom: UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + ), + child: const Row( + children: [ + UiShimmerCircle(size: 40), + SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 140, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 100, height: 12), + ], + ), + ), + UiShimmerBox(width: 60, height: 28), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_skeleton/certificates_header_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_skeleton/certificates_header_skeleton.dart new file mode 100644 index 00000000..7e41aad5 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_skeleton/certificates_header_skeleton.dart @@ -0,0 +1,37 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the certificates progress header. +class CertificatesHeaderSkeleton extends StatelessWidget { + /// Creates a [CertificatesHeaderSkeleton]. + const CertificatesHeaderSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(UiConstants.space5), + decoration: const BoxDecoration(color: UiColors.primary), + child: SafeArea( + bottom: false, + child: Column( + children: [ + const SizedBox(height: UiConstants.space4), + const UiShimmerCircle(size: 64), + const SizedBox(height: UiConstants.space3), + UiShimmerLine( + width: 120, + height: 14, + ), + const SizedBox(height: UiConstants.space2), + UiShimmerLine( + width: 80, + height: 12, + ), + const SizedBox(height: UiConstants.space6), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_skeleton/certificates_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_skeleton/certificates_skeleton.dart new file mode 100644 index 00000000..30c461d9 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_skeleton/certificates_skeleton.dart @@ -0,0 +1,38 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'certificate_card_skeleton.dart'; +import 'certificates_header_skeleton.dart'; + +/// Full-page shimmer skeleton shown while certificates are loading. +class CertificatesSkeleton extends StatelessWidget { + /// Creates a [CertificatesSkeleton]. + const CertificatesSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + child: Column( + children: [ + const CertificatesHeaderSkeleton(), + Transform.translate( + offset: const Offset(0, -UiConstants.space12), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), + child: UiShimmerList( + itemCount: 4, + spacing: UiConstants.space3, + itemBuilder: (int index) => + const CertificateCardSkeleton(), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart index 77e2a08d..353a0f70 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart @@ -10,6 +10,7 @@ import '../blocs/documents/documents_cubit.dart'; import '../blocs/documents/documents_state.dart'; import '../widgets/document_card.dart'; import '../widgets/documents_progress_card.dart'; +import '../widgets/documents_skeleton/documents_skeleton.dart'; class DocumentsPage extends StatelessWidget { const DocumentsPage({super.key}); @@ -28,11 +29,7 @@ class DocumentsPage extends StatelessWidget { child: BlocBuilder( builder: (BuildContext context, DocumentsState state) { if (state.status == DocumentsStatus.loading) { - return const Center( - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(UiColors.primary), - ), - ); + return const DocumentsSkeleton(); } if (state.status == DocumentsStatus.failure) { return Center( diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/documents_skeleton/document_card_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/documents_skeleton/document_card_skeleton.dart new file mode 100644 index 00000000..6a5149d6 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/documents_skeleton/document_card_skeleton.dart @@ -0,0 +1,37 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single document card row. +class DocumentCardSkeleton extends StatelessWidget { + /// Creates a [DocumentCardSkeleton]. + const DocumentCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + color: UiColors.cardViewBackground, + ), + child: const Row( + children: [ + UiShimmerCircle(size: 40), + SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 160, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 100, height: 12), + ], + ), + ), + UiShimmerBox(width: 24, height: 24), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/documents_skeleton/documents_progress_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/documents_skeleton/documents_progress_skeleton.dart new file mode 100644 index 00000000..e528ebb6 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/documents_skeleton/documents_progress_skeleton.dart @@ -0,0 +1,30 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the documents progress card. +class DocumentsProgressSkeleton extends StatelessWidget { + /// Creates a [DocumentsProgressSkeleton]. + const DocumentsProgressSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + color: UiColors.cardViewBackground, + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 140, height: 14), + SizedBox(height: UiConstants.space3), + UiShimmerBox(width: double.infinity, height: 8), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 80, height: 12), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/documents_skeleton/documents_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/documents_skeleton/documents_skeleton.dart new file mode 100644 index 00000000..8fdd205d --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/documents_skeleton/documents_skeleton.dart @@ -0,0 +1,32 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'document_card_skeleton.dart'; +import 'documents_progress_skeleton.dart'; + +/// Full-page shimmer skeleton shown while documents are loading. +class DocumentsSkeleton extends StatelessWidget { + /// Creates a [DocumentsSkeleton]. + const DocumentsSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: ListView( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + vertical: UiConstants.space6, + ), + children: [ + const DocumentsProgressSkeleton(), + const SizedBox(height: UiConstants.space4), + UiShimmerList( + itemCount: 5, + spacing: UiConstants.space3, + itemBuilder: (int index) => const DocumentCardSkeleton(), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart index bc350439..edeb738a 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart @@ -8,6 +8,7 @@ import 'package:krow_domain/krow_domain.dart'; import '../blocs/tax_forms/tax_forms_cubit.dart'; import '../blocs/tax_forms/tax_forms_state.dart'; import '../widgets/tax_forms_page/index.dart'; +import '../widgets/tax_forms_skeleton/tax_forms_skeleton.dart'; class TaxFormsPage extends StatelessWidget { const TaxFormsPage({super.key}); @@ -31,7 +32,7 @@ class TaxFormsPage extends StatelessWidget { child: BlocBuilder( builder: (BuildContext context, TaxFormsState state) { if (state.status == TaxFormsStatus.loading) { - return const Center(child: CircularProgressIndicator()); + return const TaxFormsSkeleton(); } if (state.status == TaxFormsStatus.failure) { diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_skeleton/tax_form_card_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_skeleton/tax_form_card_skeleton.dart new file mode 100644 index 00000000..ded5efe1 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_skeleton/tax_form_card_skeleton.dart @@ -0,0 +1,37 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single tax form card. +class TaxFormCardSkeleton extends StatelessWidget { + /// Creates a [TaxFormCardSkeleton]. + const TaxFormCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + color: UiColors.cardViewBackground, + ), + child: const Row( + children: [ + UiShimmerCircle(size: 40), + SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 120, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 80, height: 12), + ], + ), + ), + UiShimmerBox(width: 60, height: 24), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_skeleton/tax_forms_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_skeleton/tax_forms_skeleton.dart new file mode 100644 index 00000000..a60e3dba --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_skeleton/tax_forms_skeleton.dart @@ -0,0 +1,55 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'tax_form_card_skeleton.dart'; + +/// Full-page shimmer skeleton shown while tax forms are loading. +class TaxFormsSkeleton extends StatelessWidget { + /// Creates a [TaxFormsSkeleton]. + const TaxFormsSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + vertical: UiConstants.space6, + ), + child: Column( + spacing: UiConstants.space4, + children: [ + // Info card placeholder + Container( + width: double.infinity, + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + color: UiColors.cardViewBackground, + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 180, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(height: 12), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 200, height: 12), + ], + ), + ), + // Progress overview placeholder + const UiShimmerStatsCard(), + // Form card placeholders + UiShimmerList( + itemCount: 3, + spacing: UiConstants.space2, + itemBuilder: (int index) => const TaxFormCardSkeleton(), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart index 1d9fd651..c7a8bd8b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart @@ -10,6 +10,7 @@ import '../blocs/bank_account_cubit.dart'; import '../blocs/bank_account_state.dart'; import '../widgets/account_card.dart'; import '../widgets/add_account_form.dart'; +import '../widgets/bank_account_skeleton/bank_account_skeleton.dart'; import '../widgets/security_notice.dart'; class BankAccountPage extends StatelessWidget { @@ -49,7 +50,7 @@ class BankAccountPage extends StatelessWidget { builder: (BuildContext context, BankAccountState state) { if (state.status == BankAccountStatus.loading && state.accounts.isEmpty) { - return const Center(child: CircularProgressIndicator()); + return const BankAccountSkeleton(); } if (state.status == BankAccountStatus.error) { diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/bank_account_skeleton/account_card_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/bank_account_skeleton/account_card_skeleton.dart new file mode 100644 index 00000000..0cedfaff --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/bank_account_skeleton/account_card_skeleton.dart @@ -0,0 +1,37 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single bank account card. +class AccountCardSkeleton extends StatelessWidget { + /// Creates an [AccountCardSkeleton]. + const AccountCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + color: UiColors.cardViewBackground, + ), + child: const Row( + children: [ + UiShimmerCircle(size: 40), + SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 140, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 100, height: 12), + ], + ), + ), + UiShimmerBox(width: 48, height: 24), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/bank_account_skeleton/bank_account_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/bank_account_skeleton/bank_account_skeleton.dart new file mode 100644 index 00000000..539cd596 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/bank_account_skeleton/bank_account_skeleton.dart @@ -0,0 +1,32 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'account_card_skeleton.dart'; +import 'security_notice_skeleton.dart'; + +/// Full-page shimmer skeleton shown while bank accounts are loading. +class BankAccountSkeleton extends StatelessWidget { + /// Creates a [BankAccountSkeleton]. + const BankAccountSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SecurityNoticeSkeleton(), + const SizedBox(height: UiConstants.space4), + UiShimmerList( + itemCount: 2, + spacing: UiConstants.space3, + itemBuilder: (int index) => const AccountCardSkeleton(), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/bank_account_skeleton/security_notice_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/bank_account_skeleton/security_notice_skeleton.dart new file mode 100644 index 00000000..0d83d46b --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/bank_account_skeleton/security_notice_skeleton.dart @@ -0,0 +1,36 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the security notice banner. +class SecurityNoticeSkeleton extends StatelessWidget { + /// Creates a [SecurityNoticeSkeleton]. + const SecurityNoticeSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + color: UiColors.cardViewBackground, + ), + child: const Row( + children: [ + UiShimmerCircle(size: 24), + SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 160, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(height: 12), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart index 80f5a327..77aecffc 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart @@ -8,6 +8,7 @@ import 'package:krow_core/core.dart'; import '../blocs/time_card_bloc.dart'; import '../widgets/month_selector.dart'; import '../widgets/shift_history_list.dart'; +import '../widgets/time_card_skeleton/time_card_skeleton.dart'; import '../widgets/time_card_summary.dart'; /// The main page for displaying the staff time card. @@ -50,7 +51,7 @@ class _TimeCardPageState extends State { }, builder: (BuildContext context, TimeCardState state) { if (state is TimeCardLoading) { - return const Center(child: CircularProgressIndicator()); + return const TimeCardSkeleton(); } else if (state is TimeCardError) { return Center( child: Padding( diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/month_selector_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/month_selector_skeleton.dart new file mode 100644 index 00000000..d6452723 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/month_selector_skeleton.dart @@ -0,0 +1,20 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the month selector row. +class MonthSelectorSkeleton extends StatelessWidget { + /// Creates a [MonthSelectorSkeleton]. + const MonthSelectorSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + UiShimmerCircle(size: 32), + UiShimmerLine(width: 120, height: 16), + UiShimmerCircle(size: 32), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/shift_history_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/shift_history_skeleton.dart new file mode 100644 index 00000000..b045392f --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/shift_history_skeleton.dart @@ -0,0 +1,42 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single shift history row. +class ShiftHistorySkeleton extends StatelessWidget { + /// Creates a [ShiftHistorySkeleton]. + const ShiftHistorySkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + color: UiColors.cardViewBackground, + ), + child: const Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 140, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 100, height: 12), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + UiShimmerLine(width: 60, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 40, height: 12), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/time_card_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/time_card_skeleton.dart new file mode 100644 index 00000000..3d952454 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/time_card_skeleton.dart @@ -0,0 +1,37 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'month_selector_skeleton.dart'; +import 'shift_history_skeleton.dart'; +import 'time_card_summary_skeleton.dart'; + +/// Full-page shimmer skeleton shown while time card data is loading. +class TimeCardSkeleton extends StatelessWidget { + /// Creates a [TimeCardSkeleton]. + const TimeCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + vertical: UiConstants.space6, + ), + child: Column( + children: [ + const MonthSelectorSkeleton(), + const SizedBox(height: UiConstants.space6), + const TimeCardSummarySkeleton(), + const SizedBox(height: UiConstants.space6), + UiShimmerList( + itemCount: 5, + spacing: UiConstants.space3, + itemBuilder: (int index) => const ShiftHistorySkeleton(), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/time_card_summary_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/time_card_summary_skeleton.dart new file mode 100644 index 00000000..92382057 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/time_card_summary_skeleton.dart @@ -0,0 +1,19 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the time card summary (hours + earnings). +class TimeCardSummarySkeleton extends StatelessWidget { + /// Creates a [TimeCardSummarySkeleton]. + const TimeCardSummarySkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Row( + children: [ + Expanded(child: UiShimmerStatsCard()), + SizedBox(width: UiConstants.space3), + Expanded(child: UiShimmerStatsCard()), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart index 989033ab..2637c9c0 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart @@ -8,13 +8,24 @@ import 'package:krow_domain/krow_domain.dart'; import 'package:staff_attire/src/presentation/blocs/attire/attire_cubit.dart'; import 'package:staff_attire/src/presentation/blocs/attire/attire_state.dart'; -import '../widgets/attire_filter_chips.dart'; +import '../widgets/attire_empty_section.dart'; import '../widgets/attire_info_card.dart'; import '../widgets/attire_item_card.dart'; +import '../widgets/attire_section_header.dart'; +import '../widgets/attire_section_tab.dart'; +import '../widgets/attire_skeleton/attire_skeleton.dart'; -class AttirePage extends StatelessWidget { +class AttirePage extends StatefulWidget { const AttirePage({super.key}); + @override + State createState() => _AttirePageState(); +} + +class _AttirePageState extends State { + bool _showRequired = true; + bool _showNonEssential = true; + @override Widget build(BuildContext context) { final AttireCubit cubit = Modular.get(); @@ -39,10 +50,15 @@ class AttirePage extends StatelessWidget { }, builder: (BuildContext context, AttireState state) { if (state.status == AttireStatus.loading && state.options.isEmpty) { - return const Center(child: CircularProgressIndicator()); + return const AttireSkeleton(); } - final List filteredOptions = state.filteredOptions; + final List requiredItems = state.options + .where((AttireItem item) => item.isMandatory) + .toList(); + final List nonEssentialItems = state.options + .where((AttireItem item) => !item.isMandatory) + .toList(); return Column( children: [ @@ -55,55 +71,110 @@ class AttirePage extends StatelessWidget { const AttireInfoCard(), const SizedBox(height: UiConstants.space6), - // Filter Chips - AttireFilterChips( - selectedFilter: state.filter, - onFilterChanged: cubit.updateFilter, + // Section toggle chips + Row( + children: [ + AttireSectionTab( + label: 'Required', + isSelected: _showRequired, + onTap: () => setState( + () => _showRequired = !_showRequired, + ), + ), + const SizedBox(width: UiConstants.space3), + AttireSectionTab( + label: 'Non-Essential', + isSelected: _showNonEssential, + onTap: () => setState( + () => _showNonEssential = !_showNonEssential, + ), + ), + ], ), const SizedBox(height: UiConstants.space6), - // Item List - if (filteredOptions.isEmpty) - Padding( - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space10, - ), - child: Center( - child: Column( - children: [ - const Icon( - UiIcons.shirt, - size: 48, - color: UiColors.iconInactive, - ), - const SizedBox(height: UiConstants.space4), - Text( - context.t.staff_profile_attire.capture.no_items_filter, - style: UiTypography.body1m.textSecondary, - ), - ], - ), + // Required section + if (_showRequired) ...[ + AttireSectionHeader( + title: 'Required', + count: requiredItems.length, + ), + const SizedBox(height: UiConstants.space3), + if (requiredItems.isEmpty) + AttireEmptySection( + message: context + .t + .staff_profile_attire + .capture + .no_items_filter, + ) + else + ...requiredItems.map((AttireItem item) { + return Padding( + padding: const EdgeInsets.only( + bottom: UiConstants.space3, + ), + child: AttireItemCard( + item: item, + isUploading: false, + uploadedPhotoUrl: state.photoUrls[item.id], + onTap: () { + Modular.to.toAttireCapture( + item: item, + initialPhotoUrl: state.photoUrls[item.id], + ); + }, + ), + ); + }), + ], + + // Divider between sections + if (_showRequired && _showNonEssential) + const Padding( + padding: EdgeInsets.symmetric( + vertical: UiConstants.space8, ), + child: Divider(), ) else - ...filteredOptions.map((AttireItem item) { - return Padding( - padding: const EdgeInsets.only( - bottom: UiConstants.space3, - ), - child: AttireItemCard( - item: item, - isUploading: false, - uploadedPhotoUrl: state.photoUrls[item.id], - onTap: () { - Modular.to.toAttireCapture( - item: item, - initialPhotoUrl: state.photoUrls[item.id], - ); - }, - ), - ); - }), + const SizedBox(height: UiConstants.space6), + + // Non-Essential section + if (_showNonEssential) ...[ + AttireSectionHeader( + title: 'Non-Essential', + count: nonEssentialItems.length, + ), + const SizedBox(height: UiConstants.space3), + if (nonEssentialItems.isEmpty) + AttireEmptySection( + message: context + .t + .staff_profile_attire + .capture + .no_items_filter, + ) + else + ...nonEssentialItems.map((AttireItem item) { + return Padding( + padding: const EdgeInsets.only( + bottom: UiConstants.space3, + ), + child: AttireItemCard( + item: item, + isUploading: false, + uploadedPhotoUrl: state.photoUrls[item.id], + onTap: () { + Modular.to.toAttireCapture( + item: item, + initialPhotoUrl: state.photoUrls[item.id], + ); + }, + ), + ); + }), + ], const SizedBox(height: UiConstants.space20), ], ), @@ -117,3 +188,4 @@ class AttirePage extends StatelessWidget { ); } } + diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_empty_section.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_empty_section.dart new file mode 100644 index 00000000..07afd35f --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_empty_section.dart @@ -0,0 +1,24 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +class AttireEmptySection extends StatelessWidget { + const AttireEmptySection({super.key, required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: UiConstants.space6), + child: Center( + child: Column( + children: [ + const Icon(UiIcons.shirt, size: 48, color: UiColors.iconInactive), + const SizedBox(height: UiConstants.space4), + Text(message, style: UiTypography.body1m.textSecondary), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_section_header.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_section_header.dart new file mode 100644 index 00000000..b39ef5bb --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_section_header.dart @@ -0,0 +1,24 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +class AttireSectionHeader extends StatelessWidget { + const AttireSectionHeader({ + super.key, + required this.title, + required this.count, + }); + + final String title; + final int count; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text(title, style: UiTypography.headline4b), + const SizedBox(width: UiConstants.space2), + Text('($count)', style: UiTypography.body1m.textSecondary), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_section_tab.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_section_tab.dart new file mode 100644 index 00000000..365b80b4 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_section_tab.dart @@ -0,0 +1,41 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +class AttireSectionTab extends StatelessWidget { + const AttireSectionTab({ + super.key, + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space2, + ), + decoration: BoxDecoration( + color: isSelected ? UiColors.primary : UiColors.white, + borderRadius: UiConstants.radiusFull, + border: Border.all( + color: isSelected ? UiColors.primary : UiColors.border, + ), + ), + child: Text( + label, + style: isSelected + ? UiTypography.footnote2m.white + : UiTypography.footnote2m.textSecondary, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_skeleton/attire_item_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_skeleton/attire_item_skeleton.dart new file mode 100644 index 00000000..6387185d --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_skeleton/attire_item_skeleton.dart @@ -0,0 +1,37 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single attire item card. +class AttireItemSkeleton extends StatelessWidget { + /// Creates an [AttireItemSkeleton]. + const AttireItemSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + color: UiColors.cardViewBackground, + ), + child: const Row( + children: [ + UiShimmerBox(width: 56, height: 56), + SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 120, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 80, height: 12), + ], + ), + ), + UiShimmerBox(width: 60, height: 24), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_skeleton/attire_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_skeleton/attire_skeleton.dart new file mode 100644 index 00000000..3090cfd9 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_skeleton/attire_skeleton.dart @@ -0,0 +1,63 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'attire_item_skeleton.dart'; + +/// Full-page shimmer skeleton shown while attire items are loading. +class AttireSkeleton extends StatelessWidget { + /// Creates an [AttireSkeleton]. + const AttireSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Info card placeholder + Container( + width: double.infinity, + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + color: UiColors.cardViewBackground, + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 160, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(height: 12), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 200, height: 12), + ], + ), + ), + const SizedBox(height: UiConstants.space6), + // Section toggle chips placeholder + const Row( + children: [ + UiShimmerBox(width: 80, height: 32), + SizedBox(width: UiConstants.space3), + UiShimmerBox(width: 100, height: 32), + ], + ), + const SizedBox(height: UiConstants.space6), + // Section header placeholder + const UiShimmerSectionHeader(), + const SizedBox(height: UiConstants.space3), + // Attire item cards + UiShimmerList( + itemCount: 4, + spacing: UiConstants.space3, + itemBuilder: (int index) => const AttireItemSkeleton(), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/pages/emergency_contact_screen.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/pages/emergency_contact_screen.dart index ab377812..dd85406f 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/pages/emergency_contact_screen.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/pages/emergency_contact_screen.dart @@ -9,6 +9,7 @@ import '../widgets/emergency_contact_add_button.dart'; import '../widgets/emergency_contact_form_item.dart'; import '../widgets/emergency_contact_info_banner.dart'; import '../widgets/emergency_contact_save_button.dart'; +import '../widgets/emergency_contact_skeleton/emergency_contact_skeleton.dart'; /// The Staff Emergency Contact screen. /// @@ -43,7 +44,7 @@ class EmergencyContactScreen extends StatelessWidget { }, builder: (context, state) { if (state.status == EmergencyContactStatus.loading) { - return const Center(child: CircularProgressIndicator()); + return const EmergencyContactSkeleton(); } return Column( children: [ diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_info_banner.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_info_banner.dart index 00ed24a7..2592a230 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_info_banner.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_info_banner.dart @@ -7,7 +7,9 @@ class EmergencyContactInfoBanner extends StatelessWidget { @override Widget build(BuildContext context) { return UiNoticeBanner( - title: + icon: UiIcons.warning, + title: 'Emergency Contact Information', + description: 'Please provide at least one emergency contact. This information will only be used in case of an emergency during your shifts.', ); } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/contact_card_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/contact_card_skeleton.dart new file mode 100644 index 00000000..9109a538 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/contact_card_skeleton.dart @@ -0,0 +1,39 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'contact_field_skeleton.dart'; + +/// Shimmer placeholder for a single emergency contact card. +class ContactCardSkeleton extends StatelessWidget { + /// Creates a [ContactCardSkeleton]. + const ContactCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: UiConstants.space4), + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.bgPopup, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header ("Contact 1") + UiShimmerLine(width: 90, height: 16), + SizedBox(height: UiConstants.space4), + // Full Name field + ContactFieldSkeleton(), + SizedBox(height: UiConstants.space4), + // Phone Number field + ContactFieldSkeleton(), + SizedBox(height: UiConstants.space4), + // Relationship field + ContactFieldSkeleton(), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/contact_field_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/contact_field_skeleton.dart new file mode 100644 index 00000000..b376b11e --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/contact_field_skeleton.dart @@ -0,0 +1,20 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single form field (label + input). +class ContactFieldSkeleton extends StatelessWidget { + /// Creates a [ContactFieldSkeleton]. + const ContactFieldSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 80, height: 12), + SizedBox(height: UiConstants.space2), + UiShimmerBox(width: double.infinity, height: 48), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/emergency_contact_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/emergency_contact_skeleton.dart new file mode 100644 index 00000000..280e599e --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/emergency_contact_skeleton.dart @@ -0,0 +1,57 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'contact_card_skeleton.dart'; +import 'info_banner_skeleton.dart'; + +/// Full-page shimmer skeleton shown while emergency contacts are loading. +class EmergencyContactSkeleton extends StatelessWidget { + /// Creates an [EmergencyContactSkeleton]. + const EmergencyContactSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space6), + child: Column( + children: [ + // Info banner + const InfoBannerSkeleton(), + const SizedBox(height: UiConstants.space6), + // Contact card + const ContactCardSkeleton(), + const SizedBox(height: UiConstants.space4), + // Add contact button placeholder + UiShimmerBox( + width: 180, + height: 40, + borderRadius: UiConstants.radiusFull, + ), + const SizedBox(height: UiConstants.space16), + ], + ), + ), + ), + // Save button placeholder + Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: UiColors.border)), + ), + child: SafeArea( + child: UiShimmerBox( + width: double.infinity, + height: 48, + borderRadius: UiConstants.radiusLg, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/info_banner_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/info_banner_skeleton.dart new file mode 100644 index 00000000..dd1462b9 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/info_banner_skeleton.dart @@ -0,0 +1,37 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the emergency contact info banner. +class InfoBannerSkeleton extends StatelessWidget { + /// Creates an [InfoBannerSkeleton]. + const InfoBannerSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + color: UiColors.cardViewBackground, + ), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerCircle(size: 24), + SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(height: 12), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 200, height: 12), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart index a7cbf5cc..b450f4d7 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart @@ -7,6 +7,7 @@ import 'package:krow_core/core.dart'; import 'package:staff_profile_info/src/presentation/blocs/personal_info_bloc.dart'; import 'package:staff_profile_info/src/presentation/blocs/personal_info_state.dart'; import 'package:staff_profile_info/src/presentation/widgets/personal_info_page/personal_info_content.dart'; +import 'package:staff_profile_info/src/presentation/widgets/personal_info_skeleton/personal_info_skeleton.dart'; /// The Personal Info page for staff onboarding. @@ -56,7 +57,7 @@ class PersonalInfoPage extends StatelessWidget { builder: (BuildContext context, PersonalInfoState state) { if (state.status == PersonalInfoStatus.loading || state.status == PersonalInfoStatus.initial) { - return const Center(child: CircularProgressIndicator()); + return const PersonalInfoSkeleton(); } if (state.staff == null) { diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_skeleton/form_field_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_skeleton/form_field_skeleton.dart new file mode 100644 index 00000000..4fd28d9a --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_skeleton/form_field_skeleton.dart @@ -0,0 +1,20 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single form field (label + input). +class FormFieldSkeleton extends StatelessWidget { + /// Creates a [FormFieldSkeleton]. + const FormFieldSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 80, height: 12), + SizedBox(height: UiConstants.space2), + UiShimmerBox(width: double.infinity, height: 48), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_skeleton/personal_info_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_skeleton/personal_info_skeleton.dart new file mode 100644 index 00000000..0a20ab5a --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_skeleton/personal_info_skeleton.dart @@ -0,0 +1,32 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'form_field_skeleton.dart'; + +/// Full-page shimmer skeleton shown while personal info is loading. +class PersonalInfoSkeleton extends StatelessWidget { + /// Creates a [PersonalInfoSkeleton]. + const PersonalInfoSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + children: [ + // Avatar placeholder + const Center(child: UiShimmerCircle(size: 80)), + const SizedBox(height: UiConstants.space6), + // Form fields + UiShimmerList( + itemCount: 5, + spacing: UiConstants.space5, + itemBuilder: (int index) => const FormFieldSkeleton(), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_skeleton/faq_item_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_skeleton/faq_item_skeleton.dart new file mode 100644 index 00000000..14407abc --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_skeleton/faq_item_skeleton.dart @@ -0,0 +1,29 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single FAQ accordion item. +class FaqItemSkeleton extends StatelessWidget { + /// Creates a [FaqItemSkeleton]. + const FaqItemSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + color: UiColors.cardViewBackground, + ), + child: const Row( + children: [ + Expanded( + child: UiShimmerLine(height: 14), + ), + SizedBox(width: UiConstants.space3), + UiShimmerCircle(size: 20), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_skeleton/faqs_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_skeleton/faqs_skeleton.dart new file mode 100644 index 00000000..5ab1e2f8 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_skeleton/faqs_skeleton.dart @@ -0,0 +1,54 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'faq_item_skeleton.dart'; + +/// Full-page shimmer skeleton shown while FAQs are loading. +class FaqsSkeleton extends StatelessWidget { + /// Creates a [FaqsSkeleton]. + const FaqsSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: Padding( + padding: const EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space5, + UiConstants.space5, + UiConstants.space24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Search bar placeholder + UiShimmerBox( + width: double.infinity, + height: 48, + borderRadius: UiConstants.radiusLg, + ), + const SizedBox(height: UiConstants.space6), + // Category header + const UiShimmerSectionHeader(), + const SizedBox(height: UiConstants.space3), + // FAQ items + UiShimmerList( + itemCount: 3, + spacing: UiConstants.space2, + itemBuilder: (int index) => const FaqItemSkeleton(), + ), + const SizedBox(height: UiConstants.space6), + // Second category + const UiShimmerSectionHeader(), + const SizedBox(height: UiConstants.space3), + UiShimmerList( + itemCount: 3, + spacing: UiConstants.space2, + itemBuilder: (int index) => const FaqItemSkeleton(), + ), + ], + ), + ), + ); + } +} 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 index bda66591..80b1f00f 100644 --- 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 @@ -3,6 +3,7 @@ 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'; +import 'faqs_skeleton/faqs_skeleton.dart'; /// Widget displaying FAQs with search functionality and accordion items class FaqsWidget extends StatefulWidget { @@ -76,10 +77,7 @@ class _FaqsWidgetState extends State { // FAQ List or Empty State if (state.isLoading) - const Padding( - padding: EdgeInsets.symmetric(vertical: 48), - child: CircularProgressIndicator(), - ) + const FaqsSkeleton() else if (state.categories.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 48), 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 index 6faf7c3a..a7e9da46 100644 --- 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 @@ -17,17 +17,17 @@ class FaqsModule extends Module { @override void binds(Injector i) { // Repository - i.addSingleton( + i.addLazySingleton( () => FaqsRepositoryImpl(), ); // Use Cases - i.addSingleton( + i.addLazySingleton( () => GetFaqsUseCase( i(), ), ); - i.addSingleton( + i.addLazySingleton( () => SearchFaqsUseCase( i(), ), 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 index 1f9c0379..7e2cf227 100644 --- 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 @@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import '../../blocs/legal/privacy_policy_cubit.dart'; +import '../../widgets/skeletons/legal_document_skeleton.dart'; /// Page displaying the Privacy Policy document class PrivacyPolicyPage extends StatelessWidget { @@ -24,9 +25,7 @@ class PrivacyPolicyPage extends StatelessWidget { child: BlocBuilder( builder: (BuildContext context, PrivacyPolicyState state) { if (state.isLoading) { - return const Center( - child: CircularProgressIndicator(), - ); + return const LegalDocumentSkeleton(); } if (state.error != null) { 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 index e5e30c13..2be5be37 100644 --- 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 @@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import '../../blocs/legal/terms_cubit.dart'; +import '../../widgets/skeletons/legal_document_skeleton.dart'; /// Page displaying the Terms of Service document class TermsOfServicePage extends StatelessWidget { @@ -24,9 +25,7 @@ class TermsOfServicePage extends StatelessWidget { child: BlocBuilder( builder: (BuildContext context, TermsState state) { if (state.isLoading) { - return const Center( - child: CircularProgressIndicator(), - ); + return const LegalDocumentSkeleton(); } if (state.error != null) { 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 index df83b2cd..cbc8bd7b 100644 --- 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 @@ -7,6 +7,7 @@ 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'; +import '../widgets/skeletons/privacy_security_skeleton.dart'; /// Page displaying privacy & security settings for staff class PrivacySecurityPage extends StatelessWidget { @@ -25,7 +26,7 @@ class PrivacySecurityPage extends StatelessWidget { child: BlocBuilder( builder: (BuildContext context, PrivacySecurityState state) { if (state.isLoading) { - return const UiLoadingPage(); + return const PrivacySecuritySkeleton(); } return const SingleChildScrollView( diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/skeletons/legal_document_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/skeletons/legal_document_skeleton.dart new file mode 100644 index 00000000..39176a89 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/skeletons/legal_document_skeleton.dart @@ -0,0 +1,61 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shared shimmer skeleton for legal document pages (Privacy Policy, Terms). +/// +/// Simulates a long-form text document with varied line widths. +class LegalDocumentSkeleton extends StatelessWidget { + /// Creates a [LegalDocumentSkeleton]. + const LegalDocumentSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title line + const UiShimmerLine(width: 200, height: 18), + const SizedBox(height: UiConstants.space4), + // Body text lines with varied widths + UiShimmerList( + itemCount: 4, + spacing: UiConstants.space2, + itemBuilder: (int index) => const UiShimmerLine(), + ), + const SizedBox(height: UiConstants.space5), + const UiShimmerLine(width: 180, height: 16), + const SizedBox(height: UiConstants.space3), + UiShimmerList( + itemCount: 5, + spacing: UiConstants.space2, + itemBuilder: (int index) => UiShimmerLine( + width: index == 4 ? 200 : double.infinity, + ), + ), + const SizedBox(height: UiConstants.space5), + const UiShimmerLine(width: 160, height: 16), + const SizedBox(height: UiConstants.space3), + UiShimmerList( + itemCount: 3, + spacing: UiConstants.space2, + itemBuilder: (int index) => const UiShimmerLine(), + ), + const SizedBox(height: UiConstants.space5), + const UiShimmerLine(width: 140, height: 16), + const SizedBox(height: UiConstants.space3), + UiShimmerList( + itemCount: 4, + spacing: UiConstants.space2, + itemBuilder: (int index) => UiShimmerLine( + width: index == 3 ? 160 : double.infinity, + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/skeletons/privacy_security_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/skeletons/privacy_security_skeleton.dart new file mode 100644 index 00000000..85db9d2d --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/skeletons/privacy_security_skeleton.dart @@ -0,0 +1,42 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'settings_toggle_skeleton.dart'; + +/// Full-page shimmer skeleton shown while privacy settings are loading. +class PrivacySecuritySkeleton extends StatelessWidget { + /// Creates a [PrivacySecuritySkeleton]. + const PrivacySecuritySkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Privacy section header + const UiShimmerSectionHeader(), + const SizedBox(height: UiConstants.space4), + UiShimmerList( + itemCount: 3, + spacing: UiConstants.space4, + itemBuilder: (int index) => const SettingsToggleSkeleton(), + ), + const SizedBox(height: UiConstants.space6), + // Legal section header + const UiShimmerSectionHeader(), + const SizedBox(height: UiConstants.space4), + // Legal links + UiShimmerList( + itemCount: 2, + spacing: UiConstants.space3, + itemBuilder: (int index) => const UiShimmerListItem(), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/skeletons/settings_toggle_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/skeletons/settings_toggle_skeleton.dart new file mode 100644 index 00000000..fc60ed97 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/skeletons/settings_toggle_skeleton.dart @@ -0,0 +1,31 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single settings toggle row. +class SettingsToggleSkeleton extends StatelessWidget { + /// Creates a [SettingsToggleSkeleton]. + const SettingsToggleSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: UiConstants.space2), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 140, height: 14), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 200, height: 12), + ], + ), + ), + SizedBox(width: UiConstants.space3), + UiShimmerBox(width: 48, height: 28), + ], + ), + ); + } +} 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 index 22b0d405..81ce8a74 100644 --- 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 @@ -25,29 +25,29 @@ class PrivacySecurityModule extends Module { @override void binds(Injector i) { // Repository - i.addSingleton( + i.addLazySingleton( () => PrivacySettingsRepositoryImpl( Modular.get(), ), ); // Use Cases - i.addSingleton( + i.addLazySingleton( () => GetProfileVisibilityUseCase( i(), ), ); - i.addSingleton( + i.addLazySingleton( () => UpdateProfileVisibilityUseCase( i(), ), ); - i.addSingleton( + i.addLazySingleton( () => GetTermsUseCase( i(), ), ); - i.addSingleton( + i.addLazySingleton( () => GetPrivacyPolicyUseCase( i(), ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart index 5d46c536..3f5357b3 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart @@ -1,5 +1,6 @@ import 'package:bloc/bloc.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; import '../../../domain/usecases/apply_for_shift_usecase.dart'; import '../../../domain/usecases/decline_shift_usecase.dart'; import '../../../domain/usecases/get_shift_details_usecase.dart'; @@ -12,11 +13,13 @@ class ShiftDetailsBloc extends Bloc final GetShiftDetailsUseCase getShiftDetails; final ApplyForShiftUseCase applyForShift; final DeclineShiftUseCase declineShift; + final GetProfileCompletionUseCase getProfileCompletion; ShiftDetailsBloc({ required this.getShiftDetails, required this.applyForShift, required this.declineShift, + required this.getProfileCompletion, }) : super(ShiftDetailsInitial()) { on(_onLoadDetails); on(_onBookShift); @@ -34,8 +37,9 @@ class ShiftDetailsBloc extends Bloc final shift = await getShiftDetails( GetShiftDetailsArguments(shiftId: event.shiftId, roleId: event.roleId), ); + final isProfileComplete = await getProfileCompletion(); if (shift != null) { - emit(ShiftDetailsLoaded(shift)); + emit(ShiftDetailsLoaded(shift, isProfileComplete: isProfileComplete)); } else { emit(const ShiftDetailsError("Shift not found")); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_state.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_state.dart index cf6cda49..b9a0fbeb 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_state.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_state.dart @@ -14,10 +14,11 @@ class ShiftDetailsLoading extends ShiftDetailsState {} class ShiftDetailsLoaded extends ShiftDetailsState { final Shift shift; - const ShiftDetailsLoaded(this.shift); + final bool isProfileComplete; + const ShiftDetailsLoaded(this.shift, {this.isProfileComplete = false}); @override - List get props => [shift]; + List get props => [shift, isProfileComplete]; } class ShiftDetailsError extends ShiftDetailsState { diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart index 05449f48..0a56ae04 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart @@ -15,6 +15,7 @@ import '../widgets/shift_details/shift_date_time_section.dart'; import '../widgets/shift_details/shift_description_section.dart'; import '../widgets/shift_details/shift_details_bottom_bar.dart'; import '../widgets/shift_details/shift_details_header.dart'; +import '../widgets/shift_details_page_skeleton.dart'; import '../widgets/shift_details/shift_location_section.dart'; import '../widgets/shift_details/shift_schedule_summary_section.dart'; import '../widgets/shift_details/shift_stats_row.dart'; @@ -118,13 +119,14 @@ class _ShiftDetailsPageState extends State { }, builder: (context, state) { if (state is ShiftDetailsLoading) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); + return const ShiftDetailsPageSkeleton(); } final Shift displayShift = widget.shift; final i18n = Translations.of(context).staff_shifts.shift_details; + final isProfileComplete = state is ShiftDetailsLoaded + ? state.isProfileComplete + : false; final duration = _calculateDuration(displayShift); final estimatedTotal = @@ -142,6 +144,16 @@ class _ShiftDetailsPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (!isProfileComplete) + Padding( + padding: const EdgeInsets.all(UiConstants.space6), + child: UiNoticeBanner( + title: 'Complete Your Account', + description: + 'Complete your account to book this shift and start earning', + icon: UiIcons.sparkles, + ), + ), ShiftDetailsHeader(shift: displayShift), const Divider(height: 1, thickness: 0.5), ShiftStatsRow( @@ -194,20 +206,21 @@ class _ShiftDetailsPageState extends State { ), ), ), - ShiftDetailsBottomBar( - shift: displayShift, - onApply: () => _bookShift(context, displayShift), - onDecline: () => BlocProvider.of( - context, - ).add(DeclineShiftDetailsEvent(displayShift.id)), - onAccept: () => - BlocProvider.of(context).add( - BookShiftDetailsEvent( - displayShift.id, - roleId: displayShift.roleId, + if (isProfileComplete) + ShiftDetailsBottomBar( + shift: displayShift, + onApply: () => _bookShift(context, displayShift), + onDecline: () => BlocProvider.of( + context, + ).add(DeclineShiftDetailsEvent(displayShift.id)), + onAccept: () => + BlocProvider.of(context).add( + BookShiftDetailsEvent( + displayShift.id, + roleId: displayShift.roleId, + ), ), - ), - ), + ), ], ), ); @@ -314,9 +327,9 @@ class _ShiftDetailsPageState extends State { backgroundColor: UiColors.bgPopup, shape: RoundedRectangleBorder(borderRadius: UiConstants.radiusLg), title: Row( + spacing: UiConstants.space2, children: [ const Icon(UiIcons.warning, color: UiColors.error), - const SizedBox(width: UiConstants.space2), Expanded( child: Text( context.t.staff_shifts.shift_details.eligibility_requirements, @@ -336,7 +349,7 @@ class _ShiftDetailsPageState extends State { UiButton.primary( text: "Go to Certificates", onPressed: () { - Navigator.of(ctx).pop(); + Modular.to.popSafe(); Modular.to.toCertificates(); }, ), 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 2896fe8d..6f6a3a6d 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 @@ -6,6 +6,7 @@ import 'package:core_localization/core_localization.dart'; import 'package:krow_domain/krow_domain.dart'; import '../blocs/shifts/shifts_bloc.dart'; import '../utils/shift_tab_type.dart'; +import '../widgets/shifts_page_skeleton.dart'; import '../widgets/tabs/my_shifts_tab.dart'; import '../widgets/tabs/find_shifts_tab.dart'; import '../widgets/tabs/history_shifts_tab.dart'; @@ -196,7 +197,7 @@ class _ShiftsPageState extends State { // Body Content Expanded( child: state.status == ShiftsStatus.loading - ? const Center(child: CircularProgressIndicator()) + ? const ShiftsPageSkeleton() : state.status == ShiftsStatus.error ? Center( child: Padding( @@ -252,7 +253,7 @@ class _ShiftsPageState extends State { ); case ShiftTabType.find: if (availableLoading) { - return const Center(child: CircularProgressIndicator()); + return const ShiftsPageSkeleton(); } return FindShiftsTab( availableJobs: availableJobs, @@ -260,7 +261,7 @@ class _ShiftsPageState extends State { ); case ShiftTabType.history: if (historyLoading) { - return const Center(child: CircularProgressIndicator()); + return const ShiftsPageSkeleton(); } return HistoryShiftsTab(historyShifts: historyShifts); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton.dart new file mode 100644 index 00000000..85f9f266 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton.dart @@ -0,0 +1 @@ +export 'shift_details_page_skeleton/index.dart'; diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton/index.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton/index.dart new file mode 100644 index 00000000..01ee6e4c --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton/index.dart @@ -0,0 +1,2 @@ +export 'shift_details_page_skeleton.dart'; +export 'stat_card_skeleton.dart'; diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton/shift_details_page_skeleton.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton/shift_details_page_skeleton.dart new file mode 100644 index 00000000..dbb787f9 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton/shift_details_page_skeleton.dart @@ -0,0 +1,150 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'stat_card_skeleton.dart'; + +/// Shimmer loading skeleton for the shift details page. +/// +/// Mimics the loaded layout: a header with icon + text lines, a stats row +/// with three stat cards, and content sections with date/time and location +/// placeholders. +class ShiftDetailsPageSkeleton extends StatelessWidget { + /// Creates a [ShiftDetailsPageSkeleton]. + const ShiftDetailsPageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: const UiAppBar(centerTitle: false), + body: UiShimmer( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header: icon box + title/subtitle lines + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerBox( + width: 114, + height: 100, + borderRadius: UiConstants.radiusMd, + ), + const SizedBox(width: UiConstants.space4), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 180, height: 20), + SizedBox(height: UiConstants.space3), + UiShimmerLine(width: 140, height: 14), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 200, height: 12), + ], + ), + ), + ], + ), + ), + + const Divider(height: 1, thickness: 0.5), + + // Stats row: three stat cards + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Row( + children: List.generate(3, (index) { + return Expanded( + child: Padding( + padding: EdgeInsets.only( + left: index > 0 ? UiConstants.space2 : 0, + ), + child: const StatCardSkeleton(), + ), + ); + }), + ), + ), + + const Divider(height: 1, thickness: 0.5), + + // Date / time section + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const UiShimmerLine(width: 100, height: 14), + const SizedBox(height: UiConstants.space3), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + UiShimmerLine(width: 80, height: 12), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 120, height: 16), + ], + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + UiShimmerLine(width: 80, height: 12), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 120, height: 16), + ], + ), + ), + ], + ), + ], + ), + ), + + const Divider(height: 1, thickness: 0.5), + + // Location section + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + UiShimmerLine(width: 80, height: 14), + SizedBox(height: UiConstants.space3), + UiShimmerLine(height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 240, height: 12), + ], + ), + ), + + const Divider(height: 1, thickness: 0.5), + + // Description section + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + UiShimmerLine(width: 120, height: 14), + SizedBox(height: UiConstants.space3), + UiShimmerLine(height: 12), + SizedBox(height: UiConstants.space2), + UiShimmerLine(height: 12), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 200, height: 12), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton/stat_card_skeleton.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton/stat_card_skeleton.dart new file mode 100644 index 00000000..595a02b1 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton/stat_card_skeleton.dart @@ -0,0 +1,28 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Skeleton for a single stat card in the stats row. +class StatCardSkeleton extends StatelessWidget { + /// Creates a [StatCardSkeleton]. + const StatCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.bgThird, + borderRadius: UiConstants.radiusMd, + ), + child: const Column( + children: [ + UiShimmerCircle(size: 40), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 50, height: 16), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 60, height: 12), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton.dart new file mode 100644 index 00000000..e105af4b --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton.dart @@ -0,0 +1 @@ +export 'shifts_page_skeleton/index.dart'; diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton/index.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton/index.dart new file mode 100644 index 00000000..1fffff3a --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton/index.dart @@ -0,0 +1,2 @@ +export 'shift_card_skeleton.dart'; +export 'shifts_page_skeleton.dart'; diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton/shift_card_skeleton.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton/shift_card_skeleton.dart new file mode 100644 index 00000000..db661acc --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton/shift_card_skeleton.dart @@ -0,0 +1,44 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Skeleton for a single shift card matching the shift list item layout. +/// +/// Shows a rounded container with placeholder lines for the shift title, +/// time, location, and a trailing status badge. +class ShiftCardSkeleton extends StatelessWidget { + /// Creates a [ShiftCardSkeleton]. + const ShiftCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded( + child: UiShimmerLine(width: 180, height: 16), + ), + const SizedBox(width: UiConstants.space3), + UiShimmerBox( + width: 64, + height: 24, + borderRadius: UiConstants.radiusFull, + ), + ], + ), + const SizedBox(height: UiConstants.space3), + const UiShimmerLine(width: 140, height: 12), + const SizedBox(height: UiConstants.space2), + const UiShimmerLine(width: 200, height: 12), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton/shifts_page_skeleton.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton/shifts_page_skeleton.dart new file mode 100644 index 00000000..844e8cf1 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton/shifts_page_skeleton.dart @@ -0,0 +1,33 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'shift_card_skeleton.dart'; + +/// Shimmer loading skeleton for the shifts page body content. +/// +/// Mimics the loaded layout with a section header and a list of shift card +/// placeholders. Used while the initial shifts data is being fetched. +class ShiftsPageSkeleton extends StatelessWidget { + /// Creates a [ShiftsPageSkeleton]. + const ShiftsPageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const UiShimmerSectionHeader(), + const SizedBox(height: UiConstants.space3), + UiShimmerList( + itemCount: 5, + itemBuilder: (index) => const ShiftCardSkeleton(), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/shift_details_module.dart b/apps/mobile/packages/features/staff/shifts/lib/src/shift_details_module.dart index f22fc524..fba55262 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/shift_details_module.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/shift_details_module.dart @@ -1,4 +1,5 @@ import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; import 'domain/repositories/shifts_repository_interface.dart'; import 'data/repositories_impl/shifts_repository_impl.dart'; import 'domain/usecases/get_shift_details_usecase.dart'; @@ -14,11 +15,21 @@ class ShiftDetailsModule extends Module { // Repository i.add(ShiftsRepositoryImpl.new); + // StaffConnectorRepository for profile completion + i.addLazySingleton( + () => StaffConnectorRepositoryImpl(), + ); + // UseCases i.add(GetShiftDetailsUseCase.new); i.add(AcceptShiftUseCase.new); i.add(DeclineShiftUseCase.new); i.add(ApplyForShiftUseCase.new); + i.addLazySingleton( + () => GetProfileCompletionUseCase( + repository: i.get(), + ), + ); // Bloc i.add(ShiftDetailsBloc.new); diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart b/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart index 5934588f..09866f32 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart @@ -32,18 +32,18 @@ class StaffShiftsModule extends Module { ); // Repository - i.add(ShiftsRepositoryImpl.new); + i.addLazySingleton(ShiftsRepositoryImpl.new); // UseCases - i.add(GetMyShiftsUseCase.new); - i.add(GetAvailableShiftsUseCase.new); - i.add(GetPendingAssignmentsUseCase.new); - i.add(GetCancelledShiftsUseCase.new); - i.add(GetHistoryShiftsUseCase.new); - i.add(AcceptShiftUseCase.new); - i.add(DeclineShiftUseCase.new); - i.add(ApplyForShiftUseCase.new); - i.add(GetShiftDetailsUseCase.new); + i.addLazySingleton(GetMyShiftsUseCase.new); + i.addLazySingleton(GetAvailableShiftsUseCase.new); + i.addLazySingleton(GetPendingAssignmentsUseCase.new); + i.addLazySingleton(GetCancelledShiftsUseCase.new); + i.addLazySingleton(GetHistoryShiftsUseCase.new); + i.addLazySingleton(AcceptShiftUseCase.new); + i.addLazySingleton(DeclineShiftUseCase.new); + i.addLazySingleton(ApplyForShiftUseCase.new); + i.addLazySingleton(GetShiftDetailsUseCase.new); // Bloc i.add( 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 21493654..a479da35 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 @@ -26,12 +26,12 @@ class StaffMainModule extends Module { @override void binds(Injector i) { // Register the StaffConnectorRepository from data_connect - i.addSingleton( + i.addLazySingleton( StaffConnectorRepositoryImpl.new, ); // Register the use case from data_connect - i.addSingleton( + i.addLazySingleton( () => GetProfileCompletionUseCase( repository: i.get(), ), diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 7fd533da..c08e4dd6 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -141,10 +141,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -853,14 +853,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" - url: "https://pub.dev" - source: hosted - version: "0.7.2" json_annotation: dependency: transitive description: @@ -929,18 +921,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" melos: dependency: "direct dev" description: @@ -1405,6 +1397,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + shimmer: + dependency: transitive + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter @@ -1516,26 +1516,26 @@ packages: dependency: transitive description: name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a" url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.29.0" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.9" test_core: dependency: transitive description: name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943" url: "https://pub.dev" source: hosted - version: "0.6.12" + version: "0.6.15" typed_data: dependency: transitive description: diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 6f3eca62..bd577ae8 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -4,9 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -overrides: - '@dataconnect/generated': link:src/dataconnect-generated - importers: .: diff --git a/apps/web/pnpm-workspace.yaml b/apps/web/pnpm-workspace.yaml index 117a15bd..9410b45d 100644 --- a/apps/web/pnpm-workspace.yaml +++ b/apps/web/pnpm-workspace.yaml @@ -1,2 +1,5 @@ +packages: + - '.' + overrides: '@dataconnect/generated': link:src/dataconnect-generated diff --git a/apps/web/src/common/config/navigation.ts b/apps/web/src/common/config/navigation.ts index ede3d92a..7de926c6 100644 --- a/apps/web/src/common/config/navigation.ts +++ b/apps/web/src/common/config/navigation.ts @@ -62,12 +62,6 @@ export const NAV_CONFIG: NavGroup[] = [ icon: LayoutDashboard, allowedRoles: ['Vendor'], }, - { - label: 'Savings Engine', - path: '/savings', - icon: PiggyBank, - allowedRoles: ALL_ROLES, - }, { label: 'Vendor Performance', path: '/performance', @@ -117,23 +111,6 @@ export const NAV_CONFIG: NavGroup[] = [ }, ], }, - { - title: 'Marketplace', - items: [ - { - label: 'Discovery', - path: '/marketplace', - icon: ShoppingBag, - allowedRoles: ['Client', 'Admin'], - }, - { - label: 'Compare Rates', - path: '/marketplace/compare', - icon: Scale, - allowedRoles: ['Client', 'Admin'], - }, - ], - }, { title: 'Workforce', items: [ @@ -143,18 +120,6 @@ export const NAV_CONFIG: NavGroup[] = [ icon: Users, allowedRoles: ALL_ROLES, }, - { - label: 'Onboarding', - path: '/onboarding', - icon: UserPlus, - allowedRoles: ALL_ROLES, - }, - { - label: 'Teams', - path: '/teams', - icon: Users2, - allowedRoles: ALL_ROLES, - }, { label: 'Compliance', path: '/compliance', @@ -197,44 +162,4 @@ export const NAV_CONFIG: NavGroup[] = [ }, ], }, - { - title: 'Analytics & Comm', - items: [ - { - label: 'Reports', - path: '/reports', - icon: PieChart, - allowedRoles: ALL_ROLES, - }, - { - label: 'Activity Log', - path: '/activity', - icon: History, - allowedRoles: ['Vendor', 'Admin'], - }, - { - label: 'Messages', - path: '/messages', - icon: MessageSquare, - allowedRoles: ALL_ROLES, - }, - { - label: 'Tutorials', - path: '/tutorials', - icon: BookOpen, - allowedRoles: ['Client', 'Admin'], - }, - ], - }, - { - title: 'Support', - items: [ - { - label: 'Help Center', - path: '/support', - icon: HelpCircle, - allowedRoles: ['Client', 'Admin'], - }, - ], - }, ]; diff --git a/apps/web/src/features/operations/orders/components/CreateOrderDialog.tsx b/apps/web/src/features/operations/orders/components/CreateOrderDialog.tsx index 718e6e82..763e1f81 100644 --- a/apps/web/src/features/operations/orders/components/CreateOrderDialog.tsx +++ b/apps/web/src/features/operations/orders/components/CreateOrderDialog.tsx @@ -6,7 +6,7 @@ import { DialogTitle, } from "@/common/components/ui/dialog"; import EventFormWizard from "./EventFormWizard"; -import { useCreateOrder, useListBusinesses, useListHubs } from "@/dataconnect-generated/react"; +import { useCreateOrder, useListBusinesses, useListTeamHubs } from "@/dataconnect-generated/react"; import { OrderType, OrderStatus } from "@/dataconnect-generated"; import { dataConnect } from "@/features/auth/firebase"; import { useToast } from "@/common/components/ui/use-toast"; @@ -26,7 +26,7 @@ export default function CreateOrderDialog({ open, onOpenChange }: CreateOrderDia const [selectedHubId, setSelectedHubId] = React.useState(""); const { data: businessesData } = useListBusinesses(dataConnect); - const { data: hubsData } = useListHubs(dataConnect); + const { data: hubsData } = useListTeamHubs(dataConnect); const createOrderMutation = useCreateOrder(dataConnect, { onSuccess: () => { @@ -109,9 +109,9 @@ export default function CreateOrderDialog({ open, onOpenChange }: CreateOrderDia - {hubsData?.hubs.map((h) => ( + {hubsData?.teamHubs.map((h) => ( - {h.name} + {h.hubName} ))} diff --git a/backend/dataconnect/dataconnect.yaml b/backend/dataconnect/dataconnect.yaml index 9e1775d6..39e01fdb 100644 --- a/backend/dataconnect/dataconnect.yaml +++ b/backend/dataconnect/dataconnect.yaml @@ -1,5 +1,5 @@ specVersion: "v1" -serviceId: "krow-workforce-db-validation" +serviceId: "krow-workforce-db" location: "us-central1" schema: source: "./schema" @@ -7,7 +7,7 @@ schema: postgresql: database: "krow_db" cloudSql: - instanceId: "krow-sql-validation" + instanceId: "krow-sql" # schemaValidation: "STRICT" # STRICT mode makes Postgres schema match Data Connect exactly. # schemaValidation: "COMPATIBLE" # COMPATIBLE mode makes Postgres schema compatible with Data Connect. connectorDirs: ["./connector"] diff --git a/codemagic.yaml b/codemagic.yaml index d853fbba..1dd6fac4 100644 --- a/codemagic.yaml +++ b/codemagic.yaml @@ -4,50 +4,52 @@ # Reusable script for building the Flutter app client-app-android-apk-build-script: &client-app-android-apk-build-script - name: 👷🤖 Build Client App APK (Android) + name: 👷 🤖 Build Client App APK (Android) script: | dart pub global activate melos export PATH="$PATH":"$HOME/.pub-cache/bin" make mobile-install - make mobile-client-build PLATFORM=apk MODE=release + make mobile-client-build PLATFORM=apk MODE=release ENV=$ENV client-app-ios-build-script: &client-app-ios-build-script - name: 👷🍎 Build Client App (iOS) + name: 👷 🍎 Build Client App (iOS) script: | dart pub global activate melos export PATH="$PATH":"$HOME/.pub-cache/bin" make mobile-install - make mobile-client-build PLATFORM=ios MODE=release + make mobile-client-build PLATFORM=ios MODE=release ENV=$ENV staff-app-android-apk-build-script: &staff-app-android-apk-build-script - name: 👷🤖 Build Staff App APK (Android) + name: 👷 🤖 Build Staff App APK (Android) script: | dart pub global activate melos export PATH="$PATH":"$HOME/.pub-cache/bin" make mobile-install - make mobile-staff-build PLATFORM=apk MODE=release + make mobile-staff-build PLATFORM=apk MODE=release ENV=$ENV staff-app-ios-build-script: &staff-app-ios-build-script - name: 👷🍎 Build Staff App (iOS) + name: 👷 🍎 Build Staff App (iOS) script: | dart pub global activate melos export PATH="$PATH":"$HOME/.pub-cache/bin" make mobile-install - make mobile-staff-build PLATFORM=ios MODE=release + make mobile-staff-build PLATFORM=ios MODE=release ENV=$ENV # Reusable script for distributing Android to Firebase distribute-android-script: &distribute-android-script - name: 🚛🤖 Distribute Android to Firebase App Distribution + name: 🚀 🤖 Distribute Android to Firebase App Distribution script: | # Distribute Android APK - # Note: Using wildcards to catch app-release.apk - APP_PATH=$(find apps/mobile/apps -name "app-release.apk" | head -n 1) + # With flavors the APK is at: build/app/outputs/apk//release/app--release.apk + APP_PATH=$(find apps/mobile/apps -name "app-${ENV}-release.apk" | head -n 1) if [ -z "$APP_PATH" ]; then - echo "No APK found!" + echo "❌ No APK found matching app-${ENV}-release.apk — was --flavor ${ENV} applied during build?" + echo "Listing all APKs found:" + find apps/mobile/apps -name "*.apk" -type f exit 1 fi echo "Found APK at: $APP_PATH" - + firebase appdistribution:distribute "$APP_PATH" \ --app $FIREBASE_APP_ID_ANDROID \ --release-notes "Build $FCI_BUILD_NUMBER - Environment: $ENV" \ @@ -56,7 +58,7 @@ distribute-android-script: &distribute-android-script # Reusable script for distributing iOS to Firebase distribute-ios-script: &distribute-ios-script - name: 🚛🍎 Distribute iOS to Firebase App Distribution + name: 🚀 🍎 Distribute iOS to Firebase App Distribution script: | # Distribute iOS IPA_PATH=$(find apps/mobile/apps -name "*.ipa" | head -n 1) @@ -74,7 +76,7 @@ distribute-ios-script: &distribute-ios-script # Reusable script for web quality checks web-quality-script: &web-quality-script - name: ✅ Web Quality Checks + name: 🌐 ✅ Web Quality Checks script: | npm install -g pnpm cd apps/web @@ -85,7 +87,7 @@ web-quality-script: &web-quality-script # Reusable script for mobile quality checks mobile-quality-script: &mobile-quality-script - name: ✅ Mobile Quality Checks + name: 📱 ✅ Mobile Quality Checks script: | dart pub global activate melos export PATH="$PATH":"$HOME/.pub-cache/bin" @@ -98,7 +100,7 @@ workflows: # Quality workflow (Web + Mobile) # ================================================================================= quality-gates-dev: - name: ✅ Quality Gates (Dev) + name: 🛡️ Quality Gates (Dev) working_directory: . instance_type: mac_mini_m2 max_build_duration: 60 @@ -129,7 +131,7 @@ workflows: artifacts: - apps/mobile/apps/client/build/app/outputs/flutter-apk/*.apk - apps/mobile/apps/client/build/ios/ipa/*.ipa - - apps/mobile/apps/client/build/app/outputs/bundle/release/app-release.aab + - apps/mobile/apps/client/build/app/outputs/bundle/**/*.aab - /tmp/xcodebuild_logs/*.log - flutter_drive.log cache: @@ -153,7 +155,7 @@ workflows: artifacts: - apps/mobile/apps/staff/build/app/outputs/flutter-apk/*.apk - apps/mobile/apps/staff/build/ios/ipa/*.ipa - - apps/mobile/apps/staff/build/app/outputs/bundle/release/app-release.aab + - apps/mobile/apps/staff/build/app/outputs/bundle/**/*.aab - /tmp/xcodebuild_logs/*.log - flutter_drive.log cache: @@ -163,11 +165,11 @@ workflows: - $FCI_BUILD_DIR/apps/mobile/apps/staff/.dart_tool # ================================================================================= - # Client App Workflows - Android + # 💼 Client App Workflows - Android # ================================================================================= client-app-dev-android: <<: *client-app-base - name: 🚛🤖 Client App Dev (Android App Distribution) + name: 🚚 🤖 Client App Dev (Android → Firebase App Distribution) environment: flutter: stable xcode: latest @@ -175,11 +177,7 @@ workflows: groups: - client_app_dev_credentials android_signing: - - keystore: krow_client_dev - keystore_environment_variable: CM_KEYSTORE_PATH_CLIENT - keystore_password_environment_variable: CM_KEYSTORE_PASSWORD_CLIENT - key_alias_environment_variable: CM_KEY_ALIAS_CLIENT - key_password_environment_variable: CM_KEY_PASSWORD_CLIENT + - keystore: KROW_CLIENT_DEV vars: ENV: dev scripts: @@ -188,7 +186,7 @@ workflows: client-app-staging-android: <<: *client-app-base - name: 🚛🤖 Client App Staging (Android App Distribution) + name: 🚚 🤖 Client App Staging (Android → Firebase App Distribution) environment: flutter: stable xcode: latest @@ -196,29 +194,21 @@ workflows: groups: - client_app_staging_credentials android_signing: - - keystore: krow_client_staging - keystore_environment_variable: CM_KEYSTORE_PATH_CLIENT - keystore_password_environment_variable: CM_KEYSTORE_PASSWORD_CLIENT - key_alias_environment_variable: CM_KEY_ALIAS_CLIENT - key_password_environment_variable: CM_KEY_PASSWORD_CLIENT + - keystore: KROW_CLIENT_STAGING vars: - ENV: staging + ENV: stage scripts: - *client-app-android-apk-build-script - *distribute-android-script client-app-prod-android: <<: *client-app-base - name: 🚛🤖 Client App Prod (Android App Distribution) + name: 🚚 🤖 Client App Prod (Android → Firebase App Distribution) environment: groups: - client_app_prod_credentials android_signing: - - keystore: krow_client_prod - keystore_environment_variable: CM_KEYSTORE_PATH_CLIENT - keystore_password_environment_variable: CM_KEYSTORE_PASSWORD_CLIENT - key_alias_environment_variable: CM_KEY_ALIAS_CLIENT - key_password_environment_variable: CM_KEY_PASSWORD_CLIENT + - keystore: KROW_CLIENT_PROD vars: ENV: prod scripts: @@ -226,11 +216,11 @@ workflows: - *distribute-android-script # ================================================================================= - # Client App Workflows - iOS + # 💼 Client App Workflows - iOS # ================================================================================= client-app-dev-ios: <<: *client-app-base - name: 🚛🍎 Client App Dev (iOS App Distribution) + name: 🚚 🍎 Client App Dev (iOS → Firebase App Distribution) environment: groups: - client_app_dev_credentials @@ -242,19 +232,19 @@ workflows: client-app-staging-ios: <<: *client-app-base - name: 🚛🍎 Client App Staging (iOS App Distribution) + name: 🚚 🍎 Client App Staging (iOS → Firebase App Distribution) environment: groups: - client_app_staging_credentials vars: - ENV: staging + ENV: stage scripts: - *client-app-ios-build-script - *distribute-ios-script client-app-prod-ios: <<: *client-app-base - name: 🚛🍎 Client App Prod (iOS App Distribution) + name: 🚚 🍎 Client App Prod (iOS → Firebase App Distribution) environment: groups: - client_app_prod_credentials @@ -265,11 +255,11 @@ workflows: - *distribute-ios-script # ================================================================================= - # Staff App Workflows - Android + # 👨‍🍳 Staff App Workflows - Android # ================================================================================= staff-app-dev-android: <<: *staff-app-base - name: 🚛🤖👨‍🍳 Staff App Dev (Android App Distribution) + name: 🚚 🤖 👨‍🍳 Staff App Dev (Android → Firebase App Distribution) environment: flutter: stable xcode: latest @@ -277,11 +267,7 @@ workflows: groups: - staff_app_dev_credentials android_signing: - - keystore: krow_staff_dev - keystore_environment_variable: CM_KEYSTORE_PATH_STAFF - keystore_password_environment_variable: CM_KEYSTORE_PASSWORD_STAFF - key_alias_environment_variable: CM_KEY_ALIAS_STAFF - key_password_environment_variable: CM_KEY_PASSWORD_STAFF + - keystore: KROW_STAFF_DEV vars: ENV: dev scripts: @@ -290,7 +276,7 @@ workflows: staff-app-staging-android: <<: *staff-app-base - name: 🚛🤖👨‍🍳 Staff App Staging (Android App Distribution) + name: 🚚 🤖 👨‍🍳 Staff App Staging (Android → Firebase App Distribution) environment: flutter: stable xcode: latest @@ -298,20 +284,16 @@ workflows: groups: - staff_app_staging_credentials android_signing: - - keystore: krow_staff_staging - keystore_environment_variable: CM_KEYSTORE_PATH_STAFF - keystore_password_environment_variable: CM_KEYSTORE_PASSWORD_STAFF - key_alias_environment_variable: CM_KEY_ALIAS_STAFF - key_password_environment_variable: CM_KEY_PASSWORD_STAFF + - keystore: KROW_STAFF_STAGING vars: - ENV: staging + ENV: stage scripts: - *staff-app-android-apk-build-script - *distribute-android-script staff-app-prod-android: <<: *staff-app-base - name: 🚛🤖👨‍🍳 Staff App Prod (Android App Distribution) + name: 🚚 🤖 👨‍🍳 Staff App Prod (Android → Firebase App Distribution) environment: flutter: stable xcode: latest @@ -319,11 +301,7 @@ workflows: groups: - staff_app_prod_credentials android_signing: - - keystore: krow_staff_prod - keystore_environment_variable: CM_KEYSTORE_PATH_STAFF - keystore_password_environment_variable: CM_KEYSTORE_PASSWORD_STAFF - key_alias_environment_variable: CM_KEY_ALIAS_STAFF - key_password_environment_variable: CM_KEY_PASSWORD_STAFF + - keystore: KROW_STAFF_PROD vars: ENV: prod scripts: @@ -331,11 +309,11 @@ workflows: - *distribute-android-script # ================================================================================= - # Staff App Workflows - iOS + # 👨‍🍳 Staff App Workflows - iOS # ================================================================================= staff-app-dev-ios: <<: *staff-app-base - name: 🚛🍎👨‍🍳 Staff App Dev (iOS App Distribution) + name: 🚚 🍎 👨‍🍳 Staff App Dev (iOS → Firebase App Distribution) environment: groups: - staff_app_dev_credentials @@ -347,19 +325,19 @@ workflows: staff-app-staging-ios: <<: *staff-app-base - name: 🚛🍎👨‍🍳 Staff App Staging (iOS App Distribution) + name: 🚚 🍎 👨‍🍳 Staff App Staging (iOS → Firebase App Distribution) environment: groups: - staff_app_staging_credentials vars: - ENV: staging + ENV: stage scripts: - *staff-app-ios-build-script - *distribute-ios-script staff-app-prod-ios: <<: *staff-app-base - name: 🚛🍎👨‍🍳 Staff App Prod (iOS App Distribution) + name: 🚚 🍎 👨‍🍳 Staff App Prod (iOS → Firebase App Distribution) environment: groups: - staff_app_prod_credentials @@ -368,4 +346,3 @@ workflows: scripts: - *staff-app-ios-build-script - *distribute-ios-script - diff --git a/docs/05-project-onboarding-master.md b/docs/05-project-onboarding-master.md index 31eab15a..791152c9 100644 --- a/docs/05-project-onboarding-master.md +++ b/docs/05-project-onboarding-master.md @@ -1,8 +1,9 @@ # KROW Workforce Platform - Project Onboarding Master Document -> **Version:** 1.1 -> **Last Updated:** 2026-01-22 +> **Version:** 2.0 +> **Last Updated:** 2026-03-06 > **Purpose:** Source of Truth for Team Onboarding & Sprint Planning +> **Latest Milestone:** M4 (Released: March 5, 2026) --- @@ -13,7 +14,8 @@ 3. [Core Domain Logic](#3-core-domain-logic) 4. [Feature Gap Analysis](#4-feature-gap-analysis) 5. [Data Connect & Development Strategy](#5-data-connect--development-strategy) -6. [Definition of Done (DoD)](#6-definition-of-done-dod) +6. [Release Process & Automation](#6-release-process--automation) +7. [Definition of Done (DoD)](#7-definition-of-done-dod) --- @@ -160,27 +162,54 @@ graph TB - Event-driven architecture for async operations - Clean separation from data layer -3. **PostgreSQL Retention:** +3. **Core API Services:** + - Document verification service + - File upload service with signed URLs + - LLM service for AI features (RAPID orders) + - Integrated via ApiService (Dio-based) + +4. **PostgreSQL Retention:** - Full data ownership and portability - Complex queries and reporting capabilities - Industry-standard for enterprise requirements -4. **Monorepo Structure:** +5. **Monorepo Structure:** ``` krow-workforce-web/ ├── apps/ │ ├── web-dashboard/ # Vite + React │ ├── mobile/ | | ├── apps/ - | | │ ├── client/ # Flutter (business app) - | | │ └── staff/ # Flutter (staff app) + | | │ ├── client/ # Flutter (business app) - v0.0.1-m4 + | | │ └── staff/ # Flutter (staff app) - v0.0.1-m4 + | | └── packages/ + | | ├── features/ + | | │ ├── client/ # Client app features + | | │ └── staff/ # Staff app features + | | │ └── profile_sections/ # Modular profile (M4) + | | │ ├── onboarding/ # Profile info, experience, emergency + | | │ ├── compliance/ # Documents, certificates, attire + | | │ ├── finances/ # Bank, tax forms, timecard + | | │ └── support/ # FAQs, privacy & security + | | ├── core/ # Cross-cutting concerns + | | ├── data_connect/ # Backend integration + | | ├── domain/ # Entities & failures + | | ├── design_system/ # UI components & theme + | | └── core_localization/ # i18n ├── backend/ │ ├── dataconnect/ # Firebase Data Connect schemas - │ └── functions/ # Cloud Functions + │ ├── cloud-functions/ # Firebase Cloud Functions + │ ├── command-api/ # Command API service + │ └── core-api/ # Core API (verification, upload, LLM) ├── firebase/ # Firebase config ├── internal/ │ └── launchpad/ # Internal tools & prototypes + ├── .github/ + │ ├── workflows/ # GitHub Actions (release automation) + │ └── scripts/ # Release helper scripts └── docs/ # Documentation + ├── MOBILE/ # Mobile dev docs + └── RELEASE/ # Release docs ``` --- @@ -634,7 +663,92 @@ These **must be ported** from legacy: 11. **Push Notifications** - FCM integration 12. **Geofencing** - Location-based clock validation -### 4.6 Migration Strategy +### 4.6 M4 Milestone Completion Status (Released: March 5, 2026) + +**Version:** v0.0.1-m4 (both staff and client apps) + +#### Staff App M4 Achievements + +**Profile Management - 13 Subsections (✅ Complete):** + +Via modular `profile_sections/` architecture: + +**Onboarding:** +- ✅ Personal Info (name, email, phone, address, SSN) +- ✅ Experience (work history, skills, preferences) +- ✅ Emergency Contacts (safety contacts with relationship) + +**Compliance:** +- ✅ Documents (ID verification, uploads) +- ✅ Certificates (certifications with expiry tracking) +- ✅ Attire (uniform verification with camera/gallery) + +**Finances:** +- ✅ Bank Account (payout setup with validation) +- ✅ Tax Forms (I-9, W-4 forms) +- ✅ Time Card (hours log with history) + +**Support:** +- ✅ FAQs (help articles) +- ✅ Privacy & Security (settings, account management) + +**Profile Features:** +- ✅ Benefits Overview (perks information) +- ✅ Profile completion tracking + +**Architecture:** +- ✅ Modular feature structure (`profile_sections/onboarding`, `/compliance`, `/finances`, `/support`) +- ✅ Flutter Modular dependency injection +- ✅ Data Connect integration with type-safe SDKs +- ✅ Shared design system components + +#### M4 Core Features + +**Authentication:** +- ✅ Firebase phone auth (OTP flow) +- ✅ Email/password authentication +- ✅ Profile setup wizard + +**Session Management:** +- ✅ Clock in/out flow (prototype UI) +- ✅ Shift viewing and management +- ✅ Session status tracking + +**Core API Integration:** +- ✅ Document verification service +- ✅ File upload service with signed URLs +- ✅ ApiService (Dio-based) with error handling + +**Release Automation:** +- ✅ GitHub Actions workflows (product-release, hotfix-branch-creation) +- ✅ APK signing with 24 GitHub Secrets +- ✅ Automated changelog generation +- ✅ Git tag automation +- ✅ 8 helper scripts for release management + +#### Client App M4 Status + +**Completion:** 89% of 9 major feature categories + +**Key Features:** +- ✅ Authentication (email/password) +- ✅ Order management (4 types: one-time, rapid, recurring, permanent) +- ✅ Coverage dashboard +- ✅ Reports (daily ops, spend, forecast, performance, no-show) +- ✅ Hub management +- ✅ Settings and preferences + +#### Documentation M4 + +- ✅ Comprehensive release guide (`docs/RELEASE/mobile-releases.md` - 900+ lines) +- ✅ Mobile development documentation (6 files in `docs/MOBILE/`) +- ✅ Release process quick reference +- ✅ GitHub Actions workflow documentation +- ✅ APK signing setup guide +- ✅ Architecture principles and patterns +- ✅ Use case completion audit + +### 4.7 Migration Strategy ``` ┌─────────────────────────────────────────────────────────────────┐ @@ -665,9 +779,9 @@ These **must be ported** from legacy: └─────────────────────────────────────────────────────────────────┘ ``` -### 4.7 Mobile Prototype Analysis (Source Code) +### 4.8 Mobile Prototype Analysis (Source Code) -#### 4.7.1 Mobile Client App (Flutter) +#### 4.8.1 Mobile Client App (Flutter) **Location:** `internal/launchpad/prototypes-src/mobile/apps/client/` @@ -706,7 +820,7 @@ These **must be ported** from legacy: --- -#### 4.7.2 Mobile Staff App (Flutter) +#### 4.8.2 Mobile Staff App (Flutter) **Location:** `internal/launchpad/prototypes-src/mobile/apps/staff/` @@ -776,7 +890,7 @@ These **must be ported** from legacy: --- -### 4.8 Complete Feature Comparison (All 3 Apps) +### 4.9 Complete Feature Comparison (All 3 Apps) | Feature | Legacy Mobile | Web Prototype | Mobile Prototype | |---------|--------------|---------------|------------------| @@ -802,7 +916,7 @@ These **must be ported** from legacy: | **Penalty System** | ✅ | ❌ | ❌ | | **Staff Rating** | ✅ | ❌ | ❌ | -### 4.9 Source Code Locations +### 4.10 Source Code Locations | Component | Location | Tech | |-----------|----------|------| @@ -926,9 +1040,133 @@ Based on minimal dependencies: --- -## 6. Definition of Done (DoD) +## 6. Release Process & Automation -### 6.1 Feature-Level DoD (MVP Phase) +### 6.1 Versioning Strategy + +**Mobile Apps:** +- Format: `v{major}.{minor}.{patch}-{milestone}` +- Example: `v0.0.1-m4` (current release) +- Milestones: m1, m2, m3, m4, etc. +- Auto-extracted from `apps/mobile/apps/{staff|client}/pubspec.yaml` + +**Git Tags:** +- Format: `{app-name}/{version}` +- Examples: `staff/v0.0.1-m4`, `client/v0.0.1-m4` + +### 6.2 GitHub Actions Workflows + +**1. Product Release (`.github/workflows/product-release.yml`)** +- **Trigger:** Manual dispatch via GitHub UI +- **Purpose:** Automated production releases with APK signing +- **Process:** + 1. Extracts version from `pubspec.yaml` + 2. Builds signed APKs for both apps + 3. Creates GitHub release with changelog + 4. Tags release (e.g., `staff/v0.0.1-m4`) + 5. Uploads APKs as release assets +- **Secrets Required:** 24 GitHub Secrets for APK signing (see `docs/RELEASE/mobile-releases.md`) + +**2. Hotfix Branch Creation (`.github/workflows/hotfix-branch-creation.yml`)** +- **Trigger:** Manual dispatch with version input +- **Purpose:** Emergency fixes for production issues +- **Process:** + 1. Creates `hotfix/{version}` branch from latest tag + 2. Opens PR back to `dev` branch + 3. Auto-updates PR description with hotfix checklist + +**3. Helper Scripts (`.github/scripts/`)** +- `extract-version.sh` - Extracts version from pubspec.yaml +- `generate-changelog.sh` - Generates release notes from CHANGELOG +- `create-release.sh` - Creates GitHub release +- `upload-assets.sh` - Uploads APKs to release +- `build-apk.sh` - Builds signed APK +- `tag-release.sh` - Creates Git tags +- `hotfix-branch.sh` - Creates hotfix branches +- `update-pr.sh` - Updates PR descriptions + +### 6.3 APK Signing Setup + +**Required GitHub Secrets (per app):** +``` +STAFF_UPLOAD_KEYSTORE_BASE64 # Base64-encoded keystore +STAFF_UPLOAD_STORE_PASSWORD # Keystore password +STAFF_UPLOAD_KEY_ALIAS # Key alias +STAFF_UPLOAD_KEY_PASSWORD # Key password +STAFF_KEYSTORE_PROPERTIES_BASE64 # Base64-encoded key.properties + +CLIENT_UPLOAD_KEYSTORE_BASE64 +CLIENT_UPLOAD_STORE_PASSWORD +CLIENT_UPLOAD_KEY_ALIAS +CLIENT_UPLOAD_KEY_PASSWORD +CLIENT_KEYSTORE_PROPERTIES_BASE64 +``` + +### 6.4 CHANGELOG Format + +Each app maintains a separate CHANGELOG: +- `apps/mobile/apps/staff/CHANGELOG.md` +- `apps/mobile/apps/client/CHANGELOG.md` + +**Format:** +```markdown +## [Unreleased] + +### Added +- New feature descriptions + +### Changed +- Modified feature descriptions + +### Fixed +- Bug fix descriptions + +## [0.0.1-m4] - 2026-03-05 + +### Added +- Profile management with 13 subsections +- Documents & certificates management +- Benefits overview +... +``` + +### 6.5 Release Documentation + +**Comprehensive Guide:** +- Location: `docs/RELEASE/mobile-releases.md` +- Length: 900+ lines +- Contents: + - Complete versioning strategy + - CHANGELOG format and examples + - GitHub Actions workflow details + - APK signing setup (with secret generation) + - Helper scripts reference + - Troubleshooting guide + +**Quick Reference:** +- Location: `docs/MOBILE/05-release-process.md` +- Links to comprehensive guide and workflows + +### 6.6 Local Release Commands (via Makefile) + +```bash +# Build unsigned APKs locally +make build-apk-staff +make build-apk-client + +# Run GitHub Actions locally (requires act) +make test-release-workflow + +# Generate changelog entries +make changelog-staff +make changelog-client +``` + +--- + +## 7. Definition of Done (DoD) + +### 7.1 Feature-Level DoD (MVP Phase) A feature is **DONE** when: @@ -944,7 +1182,7 @@ A feature is **DONE** when: > **Note (MVP):** For rapid MVP delivery, we focus on manual/integration testing directly in applications rather than unit tests. Automated test coverage will be added post-MVP. -### 6.2 Sprint-Level DoD +### 7.2 Sprint-Level DoD A sprint is **DONE** when: @@ -956,7 +1194,7 @@ A sprint is **DONE** when: - [ ] Demo-ready for stakeholders - [ ] Documentation updated -### 6.3 Code Quality Standards +### 7.3 Code Quality Standards | Aspect | Standard | |--------|----------| @@ -967,7 +1205,7 @@ A sprint is **DONE** when: | **PRs** | Template completed, 1+ approval | | **Testing (MVP)** | Manual testing in application | -### 6.4 Commit Message Format +### 7.4 Commit Message Format ``` (): diff --git a/docs/DESIGN/product-specification.md b/docs/DESIGN/product-specification.md new file mode 100644 index 00000000..0163fa86 --- /dev/null +++ b/docs/DESIGN/product-specification.md @@ -0,0 +1,2774 @@ +# KROW Workforce Management Platform +## Product Specification for Designers + +--- + +## Document Information + +**Version**: 1.0 +**Last Updated**: March 9, 2026 +**Purpose**: This document describes the functional behavior and user experience of KROW's mobile workforce management platform from a design perspective. + +--- + +## Table of Contents + +1. [Introduction](#introduction) +2. [Platform Overview](#platform-overview) +3. [Client Application](#client-application) + - [Authentication](#client-authentication) + - [Home Dashboard](#client-home-dashboard) + - [Billing](#client-billing) + - [Coverage](#client-coverage) + - [Hubs](#client-hubs) + - [Orders](#client-orders) + - [Reports](#client-reports) + - [Settings](#client-settings) +4. [Staff Application](#staff-application) + - [Authentication](#staff-authentication) + - [Home Dashboard](#staff-home-dashboard) + - [Clock In/Out](#staff-clock-in-out) + - [Shifts](#staff-shifts) + - [Availability](#staff-availability) + - [Payments](#staff-payments) + - [Profile](#staff-profile) + - [Profile Sections](#staff-profile-sections) +5. [Glossary](#glossary) + +--- + +## Introduction + +### Purpose + +This document provides a comprehensive overview of the KROW Workforce Management Platform's mobile applications. It is designed specifically for **designers** who need to understand, redesign, or create new user experiences without needing to read code. + +### Scope + +This document covers: +- **Two mobile applications**: Client (Business) app and Staff (Worker) app +- **All features**: Complete functionality across both apps +- **User flows**: How users navigate and interact with the system +- **User stories**: What users can do and why they would do it +- **Inputs and outputs**: What data users provide and what they receive + +This document does **NOT** cover: +- Technical implementation details +- Backend systems or APIs +- Code architecture +- Performance specifications + +### How to Use This Document + +- Each feature is broken down into **user stories** following the format: + - **As a** [type of user] + - **I want to** [perform an action] + - **So that** [I achieve a goal] + +- Complex flows include **Mermaid diagrams** for visual clarity +- **Inputs** describe what users need to provide +- **Outputs** describe what users see or receive +- **Edge cases** highlight special scenarios or error conditions + +### Document Conventions + +- **Client** = Business users who hire staff +- **Staff** = Workers who accept shifts and perform jobs +- **Hub** = A business location/venue where work is performed +- **Shift** = A scheduled work period with specific start/end times +- **Order** = A staffing request placed by a client +- **Coverage** = The fulfillment status of shifts for a given day + +--- + +## Platform Overview + +### What is KROW? + +KROW is a **workforce management platform** that connects businesses (clients) with workers (staff) for flexible staffing needs. The platform consists of two mobile applications: + +1. **Client Application** - Used by businesses to: + - Request staffing for their locations + - Manage multiple business locations (hubs) + - Track worker attendance and performance + - Review and approve invoices + - Monitor business metrics and reports + +2. **Staff Application** - Used by workers to: + - Find and accept available shifts + - Set their weekly availability + - Check in and out of shifts with location verification + - Track earnings and request early payments + - Complete onboarding and compliance requirements + +### Key Concepts + +- **Hub**: A physical business location where staff work (e.g., a restaurant, warehouse, or event venue) +- **Shift**: A scheduled work period with a specific role, start time, end time, and location +- **Order**: A staffing request created by a client specifying positions needed, dates, times, and location +- **Position**: A role within a shift (e.g., Server, Cook, Warehouse Associate) +- **Coverage**: The percentage or count of filled vs. unfilled positions for a given time period +- **Invoice**: A bill generated based on completed shifts, showing worker hours and total cost +- **Reliability Score**: A metric showing how dependable a staff member is (based on attendance, punctuality, cancellations) + +--- + +# Client Application + +The Client Application is designed for **business owners and managers** who need to staff their locations, track worker performance, and manage operational costs. + +--- + +## Client: Authentication + +### Purpose +Allow business users to create accounts, sign in, and manage their authentication sessions. + +### User Stories + +#### Story 1: Create Business Account +**As a** business owner +**I want to** create a new KROW account with my company information +**So that** I can start requesting staff for my business locations + +**Task Flow:** +1. User initiates account creation process +2. User provides required business information: + - Company name + - Email address (for login and communications) + - Password (meeting security requirements) + - Password confirmation (to prevent typos) +3. System validates all provided information +4. System creates business account +5. User gains authenticated access to the platform + +**Information Required:** +- Company name (text, required) +- Business email address (email format, must be unique) +- Secure password (minimum length, complexity requirements) +- Password confirmation (must match) + +**Information Provided to User:** +- Account creation success confirmation +- Validation errors if any (e.g., "Email already in use", "Password too weak", "Passwords don't match") +- Access to authenticated features + +**Edge Cases:** +- Duplicate email: System prevents creation with clear error message +- Network interruption: System provides retry mechanism +- Invalid data format: Real-time validation feedback during input +- Incomplete information: Clear indication of what's missing + +--- + +#### Story 2: Sign In with Email +**As a** returning business user +**I want to** authenticate using my registered email and password +**So that** I can access my business data and features + +**Task Flow:** +1. User initiates authentication process +2. User provides credentials: + - Registered email address + - Account password +3. System validates credentials against stored records +4. System grants authenticated access upon successful validation + +**Information Required:** +- Email address (must match registered account) +- Password (must match stored credential) + +**Information Provided to User:** +- Authentication success confirmation +- Access to authenticated features +- Clear error messaging if credentials invalid ("Invalid credentials") + +**Edge Cases:** +- Forgotten password: System provides credential recovery mechanism (not yet implemented) +- Multiple failed attempts: Temporary access restriction may be triggered for account protection +- Network interruption: Retry capability provided + +--- + +#### Story 3: Sign In with Social Authentication +**As a** business user +**I want to** authenticate using my existing Google or Apple account +**So that** I can access the platform quickly without managing separate passwords + +**Task Flow:** +1. User selects social authentication provider (Google or Apple) +2. System initiates OAuth flow with selected provider +3. User authorizes KROW to access their account through provider interface +4. System receives authorization token from provider +5. System establishes authenticated session +6. User gains access to platform features + +**Information Required:** +- Social provider choice (Google or Apple) +- Authorization approval through provider's authentication system + +**Information Provided to User:** +- Authentication success confirmation +- Access to authenticated features +- Error message if authorization fails with retry option + +**Edge Cases:** +- User cancels authorization: Process terminated, user can retry or use alternative method +- Account doesn't exist: System may create new account automatically or indicate linking requirement +- Authorization server unavailable: Clear error message with alternative authentication options + +--- + +## Client: Home Dashboard + +### Purpose +Provide clients with a customizable dashboard showing key business metrics, quick action shortcuts, and important notifications. Users can personalize widget visibility and order. + +### User Stories + +#### Story 1: View Business Dashboard +**As a** client +**I want to** access a comprehensive overview of my key business metrics and pending tasks +**So that** I can quickly understand my business status and identify actions needed + +**Task Flow:** +1. User accesses primary business overview (default view after authentication) +2. System presents summary information modules displaying: + - Current day's coverage status + - Spending Insights : Weekly cost overview and projections + - Upcoming scheduled shifts + - Past orders with reorder capability +3. User can access detailed information for any metric area + +**Information Required:** +- None (view-only access to business data) + +**Information Provided to User:** +- Spending Insights : Weekly cost overview and projections +- Today's staff coverage status +- Past orders with reorder capability +- Upcoming shift schedule summary + +**Edge Cases:** +- No data available: Empty states with guidance prompts ("No pending invoices", "Create your first order") +- Data loading: Progressive display of information as it becomes available +- Partial data failure: Available information shown with indication of what couldn't be loaded + +--- + +#### Story 2: Customize Dashboard Layout +**As a** client +**I want to** personalize which business metrics are visible and their priority order +**So that** I can focus on information most relevant to my operational needs + +**Task Flow:** +1. User initiates dashboard customization mode +2. System enables customization capabilities: + - Metric modules become repositionable + - Visibility controls become available for each module +3. User adjusts module positions to reflect preferred priority +4. User toggles visibility for individual metrics +5. User saves customization preferences +6. System persists user preferences +7. Dashboard reflects updated layout and visible metrics + +**Information Required:** +- Module position preferences (sequential ordering) +- Module visibility preferences (shown/hidden for each) + +**Information Provided to User:** +- Feedback during customization process +- Immediate updates when toggling metrics +- Confirmation of saved preferences ("Layout saved") +- Personalized dashboard reflecting choices + +**Edge Cases:** +- All metrics hidden: System displays guidance to enable at least one metric +- Reset capability: Option to restore default configuration + +--- + +#### Story 3: Reset Dashboard to Defaults +**As a** client +**I want to** restore the dashboard to its original configuration +**So that** I can undo my customizations if they're not meeting my needs + +**Task Flow:** +1. User initiates dashboard customization mode +2. User requests restoration to default configuration +3. System requests confirmation of this action +4. User confirms restoration +5. System restores original metric order and visibility settings +6. User exits customization mode + +**Information Required:** +- User confirmation (proceed or cancel restoration) + +**Information Provided to User:** +- Confirmation prompt explaining what will be reset +- Success message ("Dashboard reset to default") +- Dashboard displaying default configuration + +--- + +## Client: Billing + +### Purpose +Manage invoices, review spending, and approve payments for completed shifts. Clients can track billing periods and drill down into invoice details. + +### User Stories + +#### Story 1: View Billing Summary +**As a** client +**I want to** review my total staffing expenditure and invoice status for a selected time period +**So that** I can understand and monitor my labor costs effectively + +**Task Flow:** +1. User accesses billing information +2. User selects time period for analysis: Today | This Week | This Month | This Quarter (default: This Week) +3. System presents comprehensive spending data: + - Total expenditure for selected period + - Spending breakdown by category or location + - Pending invoices requiring attention (count and total value) + - Historical invoice records +4. User can access additional details for any displayed information + +**Information Required:** +- Time period selection (predefined options) + +**Information Provided to User:** +- Total spending amount for period (prominently displayed) +- Spending breakdown by category +- Pending invoices summary (quantity and total amount) +- Historical invoice list with key details + +**Edge Cases:** +- No spending activity in period: Display $0 with explanatory message +- No pending invoices: Confirmation message that all invoices are processed +- Data loading: Progressive disclosure as information becomes available + +--- + +#### Story 2: Review and Approve Pending Invoice +**As a** client +**I want to** examine invoice details and approve or contest charges +**So that** I can ensure payment accuracy for completed work + +**Task Flow:** +1. User accesses pending invoices collection +2. User reviews list of all invoices awaiting approval +3. User selects specific invoice for detailed review +4. System presents comprehensive invoice information: + - Worker identification + - Work period (date and time range) + - Hours worked (with break time calculations) + - Compensation rate + - Total calculated cost + - Work location (hub) +5. User examines all details +6. User approves invoice or requests modifications +7. System processes decision and updates invoice status accordingly + +**Information Required:** +- Invoice selection (from pending list) +- Approval decision (approve or request changes) +- If requesting changes: specific discrepancy details and notes + +**Information Provided to User:** +- Complete invoice breakdown with all work details +- Worker performance notes if applicable +- Approval confirmation ("Invoice approved", moved to history) +- Change request form for documenting specific issues +- Error notification if processing fails (with retry capability) + +**Edge Cases:** +- Disputed hours: Ability to flag time discrepancies with supporting notes +- Worker attendance issues: Relevant notes displayed on invoice (lateness, absence) +- Break time adjustments: Accurate reflection in calculated hours +- Processing failure: Retry mechanism with error explanation + +--- + +#### Story 3: Review Invoice History +**As a** client +**I want to** access my past approved invoices and payment records +**So that** I can track historical spending and reference previous payments + +**Task Flow:** +1. User accesses billing information +2. User navigates to historical invoice section +3. System presents past invoice records with: + - Date of invoice + - Total payment amount + - Payment status (Paid) + - Associated location(s) +4. User can select any invoice for comprehensive details +5. Historical invoice details include all original information plus payment confirmation date + +**Information Required:** +- Invoice selection (from historical list) + +**Information Provided to User:** +- chronological list of all processed invoices +- Full details of any selected historical invoice +- Payment confirmation information + +**Edge Cases:** +- No payment history: Display message indicating no invoices have been processed yet +- Extensive history: Progressive loading mechanism for large volumes + +--- + +## Client: Coverage + +### Purpose +Provide real-time visibility into daily staffing levels, unfilled positions, and worker status. Clients can quickly identify coverage gaps and take action to fill them. + +### User Stories + +#### Story 1: View Daily Coverage Status +**As a** client +**I want to** assess which shifts have assigned workers and which remain unfilled for a specific date +**So that** I can identify staffing gaps and ensure adequate coverage + +**Task Flow:** +1. User accesses staffing coverage information +2. System displays current day's coverage by default +3. User reviews comprehensive coverage data: + - Selected date + - Coverage statistics: total shifts, filled positions, unfilled positions, coverage percentage + - Critical alerts if essential shifts lack staff + - Complete shift inventory showing: + - Worker assignment (name if filled, or unfilled status) + - Work period (start and end times) + - Required role or position + - Work location + - Current status (filled, unfilled, issue indicators like lateness) +4. User can examine all scheduled shifts + +**Information Required:** +- None (defaults to current date) + +**Information Provided to User:** +- Coverage metrics and percentages +- Status information for each shift +- Critical alerts for staffing gaps +- Complete shift details with assignment status + +**Edge Cases:** +- No shifts scheduled: Message indicating no shifts exist for selected date +- Full coverage achieved: Celebration message for 100% staffing +- Worker delays: Status indicators showing "Running late" with appropriate urgency marking +- Data loading: Progressive display as information becomes available + +--- + +#### Story 2: Select Different Date +**As a** client +**I want to** view coverage information for any specific date +**So that** I can plan ahead or review past staffing performance + +**Task Flow:** +1. User initiates date selection process +2. System presents calendar date picker +3. User selects desired date (past, present, or future) +4. System retrieves and displays coverage data for selected date +5. Date indicator updates to reflect current selection + +**Information Required:** +- Date selection (from calendar interface, any valid date) + +**Information Provided to User:** +- Coverage data specific to selected date +- Updated statistics and shift inventory +- Date confirmation showing current selection + +**Edge Cases:** +- Future date without scheduled shifts: Message indicating no shifts planned yet +- Past date: Historical data with final outcomes (completed, no-show, issues resolved) +- Current date: Real-time status information + +--- + +#### Story 3: Re-post Unfilled Shift +**As a** client +**I want to** broadcast available shifts to recruit additional workers +**So that** I can fill last-minute staffing gaps quickly + +**Task Flow:** +1. User reviews coverage showing unfilled positions +2. User identifies specific unfilled shift +3. User initiates re-posting action for that shift +4. System creates new recruitment notification to available workers +5. System confirms successful re-posting +6. Shift status updates to reflect active recruitment + +**Information Required:** +- Shift selection (from unfilled positions) +- Re-post confirmation + +**Information Provided to User:** +- Success confirmation ("Shift re-posted successfully") +- Updated shift status showing recruiting state +- Recruiting progress indicators + +**Edge Cases:** +- Past shift time: Prevention of re-posting with explanation that shift time has elapsed +- Already recruiting: Indication that shift is already being actively recruited +- No eligible workers: Notification if no workers meet shift requirements + +--- + +#### Story 4: Refresh Coverage Data +**As a** client +**I want to** obtain the most current worker assignments and status information +**So that** I can make decisions based on up-to-date staffing data + +**Task Flow:** +1. User initiates data refresh +2. System displays loading state +3. System retrieves latest coverage information from server +4. Coverage information updates with current data +5. System displays timestamp of last update + +**Information Required:** +- User-initiated refresh request + +**Information Provided to User:** +- Loading state indicator +- Updated coverage information reflecting latest changes +- Timestamp showing when data was last refreshed + +**Edge Cases:** +- No network connection: Error message with retry option +- Refresh already in progress: Prevention of duplicate requests +- No changes since last refresh: Confirmation that data is already current + +--- + +## Client: Hubs + +### Purpose +Manage business locations (hubs) where shifts take place. Clients can add, edit, view, and delete hub information. + +### User Stories + +#### Story 1: View All Hubs +**As a** client +**I want to** access comprehensive information about all my business locations +**So that** I can quickly review and manage my operational sites + +**Task Flow:** +1. User accesses business locations management +2. System presents: + - Summary information (guidance or total location count) + - Capability to add new locations + - Inventory of existing locations displaying: + - Location name + - Physical address + - Key location details +3. User can browse all locations +4. User can access detailed information for any location + +**Information Required:** +- None (view-only access to location data) + +**Information Provided to User:** +- Complete list of business locations +- Location count summary (e.g., "You have 5 active hubs") +- Quick-access to key location details + +**Edge Cases:** +- No locations registered: Empty state with guidance to add first location and prominent capability to do so + +--- + +#### Story 2: Add New Hub +**As a** client +**I want to** register a new business location in my account +**So that** I can schedule staff shifts at that site + +**Task Flow:** +1. User initiates new location creation +2. System presents location information form (creation mode) +3. User provides required location details: + - Location name (text identifier) + - Physical address (full address information, possibly multi-line) + - Cost center assignment (selection from predefined options) + - NFC tag identifier (optional for location verification) +4. User submits location information +5. System validates provided data and creates location record +6. System confirms successful creation +7. New location appears in complete locations inventory + +**Information Required:** +- Location name (required text) +- Physical address (required text, may span multiple lines) +- Cost center assignment (required selection from predefined list) +- NFC tag identifier (optional text) + +**Information Provided to User:** +- Creation success confirmation ("Hub created successfully") +- New location now available in inventory +- Return to locations overview + +**Edge Cases:** +- Incomplete required information: Submission prevented with clear indication of missing fields +- Duplicate location name: Warning provided (but may be allowed) +- Network connectivity issues: Retry mechanism offered +- Invalid data format: Real-time validation feedback + +--- + +#### Story 3: View Hub Details +**As a** client +**I want to** access comprehensive information about a specific business location +**So that** I can reference its address, cost center, and other operational details + +**Task Flow:** +1. User selects specific location from inventory +2. System presents complete location information: + - Location name + - Full physical address + - Cost center assignment + - NFC tag identifier (if assigned) + - Additional metadata (creation date, shift statistics, etc. if available) +3. User reviews information +4. User can initiate location modification + OR request location removal + OR return to locations inventory + +**Information Required:** +- Location selection (from inventory) + +**Information Provided to User:** +- Complete location details +- Capability to modify or remove location +- All associated metadata + +--- + +#### Story 4: Edit Existing Hub +**As a** client +**I want to** update information for an existing business location +**So that** I can maintain accuracy when location details change + +**Task Flow:** +1. User accesses location details +2. User initiates modification mode +3. System presents location information form (edit mode) with current values pre-populated +4. User modifies fields as needed: + - Location name + - Physical address + - Cost center assignment + - NFC tag identifier +5. User submits updated information +6. System validates and applies changes +7. System confirms successful update +8. Updated location information displayed + +**Information Required:** +- Modified field values (same structure as location creation) +- Update confirmation + +**Information Provided to User:** +- Update success confirmation ("Hub updated successfully") +- Refreshed location details reflecting changes +- Return to location details view + +**Edge Cases:** +- Modification cancellation: Changes discarded, return to unmodified details +- No changes made: Notification that no modifications were detected +- Invalid data: Validation feedback before submission allowed +- Network issues: Retry mechanism with preserved changes + +--- + +#### Story 5: Delete Hub +**As a** client +**I want to** remove a business location from my account +**So that** I can maintain a clean inventory of only active operational sites + +**Task Flow:** +1. User accesses location details +2. User initiates deletion process +3. System requests deletion confirmation with warning about permanence +4. User confirms deletion intent +5. System removes location from account +6. System confirms successful deletion +7. Location no longer appears in inventory + +**Information Required:** +- Deletion confirmation (proceed or cancel) +- Understanding of permanent action + +**Information Provided to User:** +- Deletion confirmation (\"Hub deleted successfully\") +- Updated inventory without removed location +- Return to locations overview + +**Edge Cases:** +- Location has active scheduled shifts: Additional warning about impact on shifts with confirmation +- Cancellation of deletion: Returns to details without removing location +- Location has historical data: Confirmation that historical records will be preserved even after location removal + +--- + +## Client: Orders + +### Purpose +Create staffing requests (orders) for business locations. Clients can specify positions needed, dates/times, and choose from multiple order types based on their staffing needs. + +### Order Types Overview + +- **One-Time**: Request staff for a single date (e.g., special event, busy day) +- **Recurring**: Request staff for specific days each week over a period (e.g., every Monday and Friday for 4 weeks) +- **Permanent**: Request staff for certain days indefinitely (e.g., every weekday ongoing) +- **Rapid**: Quick emergency staffing request (simplified flow) + +### User Stories + +#### Story 1: Choose Order Type +**As a** client +**I want to** select the type of staffing order that matches my business need +**So that** I can create an appropriate staffing request + +**Task Flow:** +1. User initiates order creation process +2. System presents order type options with descriptions: + - **One-Time Order** - "Need staff for a single day" + - **Recurring Order** - "Need staff on specific days each week" + - **Permanent Order** - "Need staff indefinitely for certain days" + - **Rapid Order** - "Emergency staffing needed now" +3. User selects desired order type +4. System directs to appropriate order configuration process + +**Information Required:** +- Order type selection (one of four available types) + +**Information Provided to User:** +- Clear descriptions of each order type's purpose +- Access to selected order type's configuration process + +--- + +#### Story 2: Create One-Time Order +**As a** client +**I want to** request staff for a single day +**So that** I can handle a special event or unusually busy day + +```mermaid +graph TD + A[Start: Select One-Time Order] --> B[Provide Event Name] + B --> C[Select Vendor] + C --> D[Select Date] + D --> E[Select Hub Location] + E --> F[Optional: Select Hub Manager] + F --> G[Add First Position] + G --> H{Add Another Position?} + H -->|Yes| I[Add Position] + I --> J[Specify Role
Set Count
Set Times
Set Break] + J --> H + H -->|No| K[Review Order Summary] + K --> L{Form Valid?} + L -->|No| M[Review Validation Errors] + M --> B + L -->|Yes| N[Submit Order] + N --> O[Success Confirmation] + O --> P[View Order in Orders List] +``` + +**Task Flow:** +1. User selects One-Time Order type +2. User provides base order information: + - **Event name**: Text description of the event (e.g., "Grand Opening") + - **Vendor**: Selection from available vendors + - **Date**: Calendar date selection + - **Hub**: Selection from user's registered locations + - System automatically retrieves available hub managers for selected location + - **Hub manager**: Optional manager assignment +3. User defines required positions (can add multiple): + - Initiate position addition + - Specify for each position: + - **Role**: Selection from available roles (e.g., Server, Cook, Bartender) + - **Count**: Quantity of workers needed (numeric value) + - **Start time**: Work period begin time + - **End time**: Work period end time + - **Lunch break**: Whether break is included (yes/no) + - Confirm position addition + - Position added to order +4. User can add additional positions by repeating position definition +5. User can remove positions from order as needed +6. User reviews complete order summary: + - Event details (name, date, location) + - All positions with timing details + - Total workers required +7. User submits order for processing +8. System validates all information and creates staffing request +9. System confirms successful order placement +10. User can access order in orders list, filtered to show the order date + +**Information Required:** +- Event name (text description) +- Vendor (selection from available options) +- Date (calendar date) +- Hub location (selection from user's registered locations) +- Hub manager (optional selection) +- For each position: + - Role (selection from predefined roles) + - Worker count (numeric, minimum 1) + - Start time (time value) + - End time (time value) + - Break inclusion (boolean yes/no) + +**Information Provided to User:** +- Order summary preview showing all details +- Validation feedback (which fields need attention) +- Success confirmation ("Order placed successfully") +- Access to view completed order + +**Edge Cases:** +- No positions defined: Submission prevented until at least one position added +- End time precedes start time: Validation error for that position +- Past date selected: Warning or prevention based on business rules +- No hub managers available: Manager field remains optional or shows empty +- Network failure: Retry mechanism with order data preserved +- Validation errors: Clear indication of which fields require correction + +--- + +#### Story 3: Create Recurring Order +**As a** client +**I want to** request staff for specific days each week over a defined period +**So that** I can handle predictable busy periods without creating multiple individual orders + +**Task Flow:** +1. User selects Recurring Order type +2. User provides order information (similar structure to One-Time): + - Event name + - Vendor selection + - **Start date**: First day to begin recurring schedule + - **End date**: Final day of recurring schedule (maximum 29 days from start) + - **Recurring days**: Which days of the week should repeat (Monday through Sunday) + - Hub location + - Hub manager (optional) +3. User defines required positions (same process as One-Time Order) +4. User reviews order summary displaying: + - Selected recurring weekdays + - Date range coverage + - All position requirements +5. User submits order +6. System creates individual shift postings for each selected weekday within the date range + +**Information Required:** +- Same as One-Time Order, plus: + - Start date (calendar date) + - End date (calendar date, maximum 29 days after start) + - Day selections (Monday through Sunday) + +**Information Provided to User:** +- Order summary showing complete recurrence pattern +- Success notification indicating quantity of shifts created +- Access to view all created orders + +**Edge Cases:** +- No days selected: Submission prevented until at least one weekday chosen +- End date precedes start date: Validation error +- Date range exceeds 29 days: Error message with maximum limit explanation +- Single day selected: System processes as valid recurring pattern for that day + +--- + +#### Story 4: Create Permanent Order +**As a** client +**I want to** request staff for certain days indefinitely +**So that** I can fill long-term positions without specifying an end date + +**Task Flow:** +1. User selects Permanent Order type +2. User provides order information (similar to Recurring): + - Event name + - Vendor selection + - **Start date**: When ongoing staffing begins (no end date required) + - **Recurring days**: Which days of the week (Monday through Sunday) + - Hub location + - Hub manager (optional) +3. User defines required positions +4. User reviews order summary displaying: + - "Permanent" or "Ongoing" status indicator + - Start date + - Selected recurring weekdays + - All position requirements +5. User submits order +6. System creates ongoing shift postings without defined end date + +**Information Required:** +- Same as Recurring Order, but only start date (no end date) +- Day selections (Monday through Sunday) + +**Information Provided to User:** +- Order summary with "Permanent" status indication +- Success confirmation +- Access to view permanent order + +**Edge Cases:** +- No days selected: Submission prevented until at least one weekday chosen +- Modifying or canceling permanent order: Requires separate management action (not covered in creation flow) + +--- + +#### Story 5: Create Rapid Order +**As a** client +**I want to** quickly request emergency staffing +**So that** I can fill urgent last-minute needs efficiently + +**Task Flow:** +1. User selects Rapid Order type +2. System presents simplified order creation process: + - Possibly voice input capability or quick templates + - Only essential fields required (location, role, count, timing) +3. User provides minimal required information +4. User submits immediate staffing request +5. System fast-tracks order with high priority to available workers + +**Information Required:** +- Minimal essential fields (specific requirements TBD based on rapid_order implementation) +- Possibly voice description capability + +**Information Provided to User:** +- Rapid confirmation of request received +- Immediate visibility of order to eligible workers + +**Edge Cases:** +- Time-critical situations requiring fastest possible response +- Higher visibility or priority level to worker community +- Simplified validation for speed + +--- + +#### Story 6: View All Orders +**As a** client +**I want to** access a comprehensive list of all staffing orders I've created +**So that** I can monitor their status and details + +**Task Flow:** +1. User accesses orders management area +2. System presents orders in organized format (calendar or list structure) +3. User can filter by date range if desired +4. Order entries display key information: + - Order date(s) or date range + - Order type (One-Time, Recurring, Permanent) + - Associated location(s) + - Position count + - Fill status (e.g., "5 of 10 positions filled") +5. User can access detailed information for any order + +**Information Required:** +- Optional date range filter +- Refresh capability for updated information + +**Information Provided to User:** +- Complete inventory of all orders +- Status indicators for each order +- Fill progress tracking + +**Edge Cases:** +- No orders created yet: Guidance prompt to create first order +- Cancelled orders: Display with "Cancelled" status +- Very large order history: Progressive loading mechanism + +--- + +## Client: Reports + +### Purpose +Provide comprehensive business intelligence through various report types. Clients can track KPIs, analyze spending, monitor performance, and make data-driven decisions. + +### User Stories + +#### Story 1: View Reports Summary +**As a** client +**I want to** access a high-level overview of key business metrics for a selected time period +**So that** I can quickly understand my business performance + +**Task Flow:** +1. User accesses business reports area +2. User views period options: Today | Week | Month | Quarter +3. User selects desired time period (default: Week) +4. System presents reports overview displaying: + - Summary metric information (total orders, fill rate, total expenditure) + - Access points for detailed report categories: + - Daily Operations analysis + - Performance metrics + - Spend Analysis + - Coverage analysis + - Forecast projections + - No-Show Tracking +5. User can access any detailed report category + +**Information Required:** +- Period selection (predefined options) + +**Information Provided to User:** +- Summary metrics for chosen period +- Access to all detailed report types + +**Edge Cases:** +- No data for selected period: Message indicating no activity during timeframe + +--- + +#### Story 2: View Performance Report (KPIs) +**As a** client +**I want to** see my business performance KPIs with status context +**So that** I can identify areas needing improvement + +**Task Flow:** +1. User accesses Performance Report +2. User selects time period for analysis +3. System presents overview displaying: + - **Overall Performance Score**: 0-100 with rating (Excellent ≥90, Good 75-89, Needs Work <75) + - **4 Key Performance Indicators**: + - **Fill Rate**: Percentage of positions filled (Target: 95%) + - **Completion Rate**: Percentage of shifts completed without issues + - **On-Time Rate**: Percentage of workers arriving on time + - **Average Fill Time**: How quickly positions are filled (Target: 3 hours) +4. Each KPI shows current value, percentage, and comparison to target +5. User can change period to see trends over time + +**Information Required:** +- Time period selection + +**Information Provided to User:** +- Overall performance score with rating label +- Individual KPI values with percentage and target comparison +- Trend data when different periods are selected + +**Edge Cases:** +- Insufficient data: Message indicating "Need more data to calculate" +- All KPIs meeting targets: Positive feedback message + +--- + +#### Story 3: View Spend Report +**As a** client +**I want to** analyze my staffing costs over time +**So that** I can manage my budget and identify cost-optimization opportunities + +**Task Flow:** +1. User accesses Spend Report from reports overview +2. User selects time period (week with Monday-Sunday view, or custom date range) +3. System presents financial analysis displaying: + - **Total Spend**: Prominently displayed total expenditure for selected period + - **Spending Breakdown** across multiple dimensions: + - By business location + - By role or position type + - By day of week + - Cost per hour metrics +4. User can drill into breakdown sections for additional detail + +**Information Required:** +- Time period selection (week or custom date range) + +**Information Provided to User:** +- Total expenditure amount +- Multi-dimensional spending breakdown +- Trend analysis over time + +**Edge Cases:** +- No expenditure in period: Display $0 with explanatory message +- Significant spending anomalies: Highlighted with warning indicators + +--- + +#### Story 4: View Daily Operations Report +**As a** client +**I want to** access a comprehensive snapshot of operations for a specific date +**So that** I can review that day's performance and activities + +**Task Flow:** +1. User accesses Daily Operations report from reports overview +2. User selects specific date +3. System presents operational analysis displaying: + - Orders created that day (count) + - Positions filled (count and percentage) + - Total staffing expenditure for the day + - Worker attendance summary + - Issues or incidents if any occurred +4. User reviews all operational metrics + +**Information Required:** +- Date selection + +**Information Provided to User:** +- Complete operational metrics for chosen date +- Summary information with counts and financial amounts +- Attendance and performance indicators + +**Edge Cases:** +- Future date selected: Indication that data not yet available +- No activity on date: Message indicating no operations occurred +- Incomplete data: Clear indication of what information is still pending + +--- + +#### Story 5: View Coverage Report +**As a** client +**I want to** analyze my shift fill rates over time +**So that** I can identify patterns and improve my staffing strategy + +**Task Flow:** +1. User accesses Coverage Report from reports overview +2. User selects date range for analysis +3. System presents coverage analysis displaying: + - Overall coverage percentage (e.g., 87% of shifts filled) + - Unfilled positions count with alerts + - Multi-dimensional breakdown: + - By business location + - By position type + - By day of week + - By time of day + - Trend analysis showing coverage changes over time +4. User identifies patterns in low-coverage periods or locations + +**Information Required:** +- Date range selection + +**Information Provided to User:** +- Overall coverage percentage +- Detailed unfilled positions inventory +- Multi-dimensional breakdown analysis +- Trend analysis revealing patterns + +**Edge Cases:** +- 100% coverage achieved: Success celebration message +- Chronic low coverage areas: Highlighted with improvement recommendations +- Insufficient data: Indication that broader date range would provide better analysis + +--- + +#### Story 6: View Forecast Report +**As a** client +**I want to** access predicted staffing demand and supply trends +**So that** I can plan proactively and avoid staffing shortages + +**Task Flow:** +1. User accesses Forecast Report from reports overview +2. System presents predictive analysis displaying: + - Projected staffing demand for upcoming weeks + - Available worker supply trend projections + - Gap analysis comparing demand against supply + - Strategic recommendations (e.g., "Consider posting shifts earlier to improve fill rates") +3. User reviews projections and trend analysis + +**Information Required:** +- Optional date range for forecast period + +**Information Provided to User:** +- Demand trend projections +- Supply trend projections +- Gap analysis identifying potential shortfalls +- Actionable recommendations for optimization + +**Edge Cases:** +- New account with limited historical data: Message indicating forecast model is being developed +- Highly variable patterns: Wider confidence intervals shown +- Stable demand: High confidence projections with reinforcement + +--- + +#### Story 7: View No-Show Report +**As a** client +**I want to** track worker reliability and shift attendance issues +**So that** I can address recurring problems and improve operational consistency + +**Task Flow:** +1. User accesses No-Show Report from reports overview +2. User selects date range for analysis +3. System presents reliability analysis displaying: + - Total no-show occurrences and rate (percentage of scheduled shifts) + - Workers flagged for multiple no-show incidents + - Shifts most frequently affected by no-shows + - Breakdown by business location +4. User reviews data to identify reliability patterns + +**Information Required:** +- Date range selection + +**Information Provided to User:** +- No-show count and percentage rate +- Worker list with incident counts +- Location-based breakdown +- Recommendations for reliability improvement + +**Edge Cases:** +- Perfect attendance record: Celebration message for zero no-shows +- High no-show rate: Alert highlighting the issue with suggested actions +- Repeated offenders: Clear identification for potential intervention + +--- + +## Client: Settings + +### Purpose +Manage user profile information, account preferences, and app settings. Clients can update their personal details and sign out. + +### User Stories + +#### Story 1: View Profile and Settings +**As a** client +**I want to** access my profile information and account configuration options +**So that** I can verify my details and access account management capabilities + +**Task Flow:** +1. User accesses account settings area +2. System displays: + - Profile information: + - Profile picture or avatar + - User name + - Company name + - Configuration options: + - Profile editing capability + - Preferences (if applicable) + - Help and Support access (if applicable) + - Sign out capability +3. User reviews information and available options + +**Information Required:** +- None (view-only access to profile data) + +**Information Provided to User:** +- Complete profile information display +- Available settings and configuration menu options + +--- + +#### Story 2: Edit Profile Information +**As a** client +**I want to** update my personal information +**So that** my account details remain current and accurate + +**Task Flow:** +1. User initiates profile editing process +2. System presents profile information form with current values: + - Profile picture (with capability to change) + - First name + - Last name + - Email address + - Phone number +3. User modifies desired fields +4. User submits updated information +5. System validates and applies changes +6. System confirms successful update +7. User returns to settings view with updated information + +**Information Required:** +- Profile picture (image file) +- First name (text) +- Last name (text) +- Email address (valid email format) +- Phone number (valid phone format) + +**Information Provided to User:** +- Update success confirmation ("Profile updated successfully") +- Refreshed profile information in settings +- Return to settings overview + +**Edge Cases:** +- Invalid email format: Validation error with format guidance +- Required fields empty: Submission prevented until complete +- Modification cancellation: Changes discarded, return to unmodified state +- Email already registered to another account: Error message indicating conflict +- Network issues: Retry mechanism with preserved changes + +--- + +#### Story 3: Sign Out +**As a** client +**I want to** terminate my authenticated session +**So that** I can secure my account when not in use + +**Task Flow:** +1. User initiates sign out process +2. System requests confirmation: "Are you sure you want to sign out?" +3. User confirms intent to sign out +4. System terminates authenticated session +5. User returned to authentication entry point + +**Information Required:** +- Sign out confirmation (proceed or cancel) + +**Information Provided to User:** +- Sign out confirmation +- Access to authentication entry point +- Session cleared confirmation + +**Edge Cases:** +- Cancellation of sign out: Return to settings without terminating session +- Automatic timeout: Session termination after period of inactivity (if implemented) +- Unsaved changes elsewhere: Warning about potential data loss (if applicable) + +--- + +# Staff Application + +The Staff Application is designed for **workers** who want to find shifts, track their work, manage availability, and get paid. + +--- + +## Staff: Authentication + +### Purpose +Allow workers to sign up and sign in using phone number verification. This includes a multi-step profile setup wizard for new users. + +### User Stories + +#### Story 1: Sign Up with Phone Number +**As a** new worker +**I want to** create an account using my phone number +**So that** I can start finding work through KROW + +```mermaid +graph TD + A[Start: Open App] --> B[Begin Registration] + B --> C[Choose Sign Up] + C --> D[Provide Phone Number
10-digit US format] + D --> E{Phone Valid?} + E -->|No| F[Validation Error] + F --> D + E -->|Yes| G[Request Verification Code] + G --> H[31-second Cooldown Period] + H --> I[Receive SMS with OTP] + I --> J[Provide 6-digit OTP] + J --> K{OTP Correct?} + K -->|No| L[Verification Error
Retry Available] + L --> J + K -->|Yes| M[Account Created] + M --> N[Begin Profile Setup] + N --> O[Step 1: Basic Info] + O --> P[Step 2: Location Preferences] + P --> Q[Step 3: Experience/Skills] + Q --> R[Submit Profile] + R --> S[Access Home Dashboard] +``` + +**Task Flow - Phone Verification:** +1. User opens application and initiates account creation process +2. User selects registration option +3. System presents phone verification process +4. User provides phone number: + - 10 digits for US numbers + - System automatically formats as input progresses (e.g., (555) 123-4567) +5. User requests verification code delivery +6. System sends SMS with 6-digit OTP +7. System initiates cooldown timer (31 seconds before resend capability) +8. User receives SMS and provides 6-digit OTP +9. System verifies OTP authenticity +10. Upon successful verification, system creates user account +11. User proceeds to Profile Setup wizard + +**Information Required:** +- Phone number (10 digits, automatically formatted) +- OTP code (6 digits from SMS) + +**Information Provided to User:** +- SMS delivery confirmation: "Code sent to (555) 123-4567" +- Cooldown timer: "Resend available in 31s" +- Successful verification: Proceed to Profile Setup +- Verification failure: "Invalid code. Please try again." with retry capability + +**Edge Cases:** +- Invalid phone format: Immediate error message display +- OTP expiration: Capability to request new code +- Excessive failed attempts: Temporary account lock with "Try again in X minutes" message +- SMS not received: "Resend Code" capability available after cooldown period +- Network issues: Retry mechanism for code delivery + +--- + +#### Story 2: Complete Profile Setup Wizard +**As a** new worker +**I want to** complete my profile setup through guided steps +**So that** clients can discover me and I can be matched to appropriate work opportunities + +**Task Flow - 3-Step Wizard:** + +**Step 1: Basic Information** +1. User sees progress indicator ("Step 1 of 3") +2. User provides full name (minimum 2 characters) +3. User proceeds to next step + +**Step 2: Location Preferences** +1. User sees progress indicator ("Step 2 of 3") +2. System presents multi-select list of available work locations/areas +3. User selects preferred work locations (minimum one required) +4. User can return to previous step or proceed forward + +**Step 3: Experience & Skills** +1. User sees progress indicator ("Step 3 of 3") +2. System presents multi-select list of roles/skills (e.g., Server, Cook, Warehouse) +3. User selects all applicable skills (minimum one required) +4. User can return to previous step or complete setup +5. User submits completed profile information +6. System validates all provided information +7. Upon validation success, user gains access to main application + +**Information Required:** +- **Step 1**: Full name (text, minimum 2 characters) +- **Step 2**: Location preferences (multi-select, minimum 1 selection) +- **Step 3**: Skills and experience (multi-select, minimum 1 selection) + +**Information Provided to User:** +- Progress indicators throughout wizard (1 of 3, 2 of 3, 3 of 3) +- Navigation controls (back, next, submit capabilities) +- Validation feedback for incomplete or invalid data +- Welcome confirmation: "Welcome to KROW!" +- Access to main application features + +**Edge Cases:** +- Required fields incomplete: Forward progression prevented until requirements met +- Returning to authentication from Step 1: May result in progress loss (warning appropriate) +- Application closure during setup: Progress preserved for later completion +- Network interruption: Setup data preserved locally for retry + +--- + +#### Story 3: Sign In with Phone Number +**As a** returning worker +**I want to** authenticate using my phone number +**So that** I can quickly access my account + +**Task Flow:** +1. User initiates authentication process +2. System presents phone verification (same process as registration) +3. User provides phone number +4. User requests verification code +5. System sends OTP via SMS +6. User provides received OTP +7. System verifies OTP authenticity +8. Upon successful verification, user gains authenticated access +9. User accesses main application + +**Information Required:** +- Phone number (10 digits) +- OTP code (6 digits from SMS) + +**Information Provided to User:** +- SMS delivery confirmation +- Successful authentication: Access to main application +- Authentication failure: "Invalid code" with retry capability + +**Edge Cases:** +- Unregistered phone number: Error message "No account found. Please sign up." +- Incomplete profile: Automatic redirect to Profile Setup wizard at last incomplete step +- All standard OTP edge cases (expiration, too many attempts, SMS delivery issues) + +--- + +## Staff: Home Dashboard + +### Purpose +Provide workers with a personalized dashboard showing shift summaries, recommendations, benefits overview, and quick actions. + +### User Stories + +#### Story 1: View Shift Summary +**As a** worker +**I want to** access my upcoming shifts and discover recommended opportunities +**So that** I can plan my schedule and find new work + +**Task Flow:** +1. User accesses main application overview +2. System presents personalized information: + - **Personal greeting**: "Hello, [Worker Name]" + - **Today's Schedule**: + - All shifts scheduled for current day + - Each displaying: Time, location, role, current status + - **Tomorrow's Schedule**: + - Preview of next day's commitments + - **Recommended Opportunities**: + - Algorithm-suggested shifts based on preferences and work history + - Capability to browse complete opportunity marketplace + - **Benefits Summary**: + - Quick overview of benefits information + - Access to detailed benefits information +3. User can browse all information sections +4. User can access detailed information for any individual shift +5. User can access complete shift marketplace + +**Information Required:** +- None (view-only access to dashboard data) + +**Information Provided to User:** +- Personalized greeting +- Today's complete shift schedule +- Tomorrow's shift preview +- Algorithmically recommended shifts +- Benefits information summary +- Quick access to key features + +**Edge Cases:** +- No shifts scheduled today: Message "No shifts scheduled today" with access to shift discovery +- Incomplete profile: System notification prompting profile completion to unlock shift recommendations and features +- No recommended shifts: Alternative messaging suggesting profile enhancement or shift marketplace browsing + +--- + +#### Story 2: Enable Auto-Match for Shifts +**As a** worker +**I want to** automatically receive shift matches based on my preferences +**So that** I don't miss opportunities without constantly monitoring the application + +**Task Flow:** +1. User sees auto-match capability toggle in application +2. User activates auto-match feature +3. System confirms activation: "Auto-match enabled. You'll be notified when shifts matching your preferences are available." +4. User receives push notifications when suitable shifts are identified +5. User can deactivate auto-match by toggling off + +**Information Required:** +- Auto-match preference (enabled/disabled) + +**Information Provided to User:** +- Activation confirmation message +- Push notifications when matching shifts are found +- Settings indicator showing auto-match status + +**Edge Cases:** +- Incomplete profile: Auto-match unavailable with guidance "Complete your profile to enable auto-match" +- Push notifications disabled: Prompt to enable device notification permissions +- No matching shifts found: Suggestions to broaden preferences or check back later + +--- + +#### Story 3: View Benefits Information +**As a** worker +**I want to** learn about benefits available to me +**So that** I understand the complete value proposition of working through KROW + +**Task Flow:** +1. User accesses benefits information from overview +2. System presents comprehensive benefits overview displaying: + - Complete list of available benefits (e.g., health insurance, early pay, performance bonuses) + - Detailed description of each benefit + - Eligibility requirements for each benefit + - Instructions for accessing or enrolling in each benefit +3. User reviews all benefits information +4. User returns to main overview + +**Information Required:** +- None (view-only access to benefits data) + +**Information Provided to User:** +- Complete benefits inventory with descriptions +- Eligibility criteria for each benefit +- Enrollment or access instructions +- Current eligibility status if applicable + +**Edge Cases:** +- Not yet qualified for benefits: Message indicating "Complete more shifts to unlock benefits" with progress tracking +- Partially eligible: Clear indication of which benefits are currently accessible +- Enrollment required: Call-to-action for benefits requiring active enrollment + +--- + +## Staff: Clock In Out + +### Purpose +Track worker attendance with location verification. Workers can check in and out of shifts, log break times, and enable commute tracking. + +### User Stories + +#### Story 1: Check In to Shift with Location Verification +**As a** worker +**I want to** register my arrival to a shift with automatic location verification \n**So that** I confirm my presence and initiate time tracking + +```mermaid +graph TD + A[Start: Access Clock In] --> B[Load Today's Shifts] + B --> C{Multiple Shifts?} + C -->|Yes| D[Select Specific Shift] + C -->|No| E[Shift Auto-Selected] + D --> F[Request Location Permission] + E --> F + F --> G{Permission Granted?} + G -->|No| H[Error: Location Required] + G -->|Yes| I[Acquire Current Location] + I --> J[Calculate Distance from Venue] + J --> K{Within 500m?} + K -->|No| L[Warning: Too Far from Venue] + K -->|Yes| M[Enable Check-In] + M --> N{Confirmation Method?} + N -->|Swipe| O[Swipe Gesture Confirmation] + N -->|Action| P[Direct Confirmation] + O --> Q[Optional: Provide Check-In Notes] + P --> Q + Q --> R[Submit Check-In] + R --> S[Success: Arrival Registered] + S --> T[Display Check-Out Capability
Show Break Logging
Show Commute Tracking] +``` + +**Task Flow:** +1. User accesses attendance tracking area +2. System loads today's scheduled shifts +3. Shift selection:\n - If multiple shifts scheduled: User selects desired shift\n - If single shift: System auto-selects\n4. System requests location access permission (if not previously granted)\n5. User grants location access\n6. System acquires user's current geographical position\n7. System calculates distance from designated shift venue\n8. If within 500 meter radius:\n - Check-in capability becomes available\n - Distance information displayed (e.g., \"120m away\")\n9. User can register arrival via two methods:\n - **Gesture confirmation**: Swipe action across designated area\n - **Direct confirmation**: Direct action submission\n10. Optional notes interface appears (user can provide additional information or skip)\n11. User confirms arrival registration\n12. System confirms successful check-in: \"Checked in to [Shift Name]\"\n13. Interface updates to show:\n - Check-in timestamp\n - Break logging capability\n - Check-out capability\n - Optional: Commute tracking features\n\n**Information Required:**\n- Location permission (system request)\n- Shift selection (if multiple available)\n- Check-in confirmation (gesture or direct action)\n- Optional arrival notes (text)\n\n**Information Provided to User:**\n- Current distance from venue location\n- Location verification status\n- Check-in confirmation with precise timestamp\n- Updated interface showing departure registration capability\n\n**Edge Cases:**\n- **Location permission denied**: Error message \"Location access required to check in\" with guidance to device settings\n- **Distance exceeds threshold** (>500m): Warning \"You're too far from the venue. Move closer to check in.\" with actual distance displayed\n- **GPS signal unavailable**: Error \"Unable to determine location. Check your connection.\"\n- **Already registered arrival**: Display \"Already checked in at [time]\" with departure registration capability\n- **Incorrect shift selected**: User can modify selection before arrival confirmation\n- **Network connectivity issues**: Queue check-in for submission when connection restored + +--- + +#### Story 2: Log Break Time +**As a** worker +**I want to** record when I take breaks +**So that** my break time is accurately tracked and properly deducted from billable hours + +**Task Flow:** +1. User has registered arrival to shift +2. System displays break logging capability +3. User initiates break period recording +4. System displays running timer tracking break duration +5. User completes break and ends break period recording +6. System records total break duration +7. Optional: User can categorize break type (lunch, rest, etc.) + +**Information Required:** +- Break start (user-initiated) +- Break end (user-initiated) +- Optional: Break type classification + +**Information Provided to User:** +- Active break timer display +- Total break time recorded +- Confirmation of break logging + +**Edge Cases:** +- Forgot to end break: Capability to manually adjust break duration +- Multiple breaks: System tracks each break period independently with cumulative tracking +- System interruption: Break timer continues in background, recovers on re-access + +--- + +#### Story 3: Check Out of Shift +**As a** worker +**I want to** register my departure from a shift +**So that** my work time is fully recorded for compensation + +**Task Flow:** +1. User has registered arrival and completed work +2. User initiates departure registration +3. Optional notes interface appears +4. User provides additional information (if desired) or skips +5. User confirms departure +6. System verifies location again (same 500m proximity requirement) +7. System records departure timestamp +8. System calculates total work time (arrival - departure minus breaks) +9. System presents work summary displaying: + - Arrival time + - Departure time + - Total hours worked + - Break time deducted + - Estimated compensation (if available) + +**Information Required:**\n- Departure confirmation\n- Optional departure notes (text)\n- Location verification\n\n**Information Provided to User:**\n- Departure confirmation with precise timestamp\n- Comprehensive work summary (hours worked, breaks taken, estimated pay)\n- Complete time tracking information\n\n**Edge Cases:**\n- Departure distance exceeds venue threshold: Warning message but may allow with approval workflow\n- Forgot to register departure: Supervisor manual adjustment capability or automatic departure at scheduled shift end\n- Early departure: Warning \"Shift not yet complete. Confirm early check-out?\" with acknowledgment required\n- Network issues: Queue departure registration for submission when connected + +--- + +#### Story 4: Enable Commute Tracking +**As a** worker +**I want to** enable commute tracking +**So that** clients can monitor my estimated arrival time + +**Task Flow:** +1. After registering shift arrival, user sees commute tracking capability +2. User enables commute tracking +3. System begins continuous location monitoring +4. System calculates estimated time of arrival to venue +5. ETA information displayed to user and visible to client +6. System provides real-time updates of distance and ETA +7. When user proximity reaches venue (distance < 50m), system automatically disables commute mode + +**Information Required:** +- Commute tracking preference (enabled/disabled) +- Continuous location updates + +**Information Provided to User:** +- Estimated arrival time (e.g., "Arriving in 12 minutes") +- Distance to venue (e.g., "2.3 km away") +- Real-time progress updates + +**Edge Cases:** +- Location tracking interruption: System displays last known position +- Arrival but ETA persisting: Auto-clears when within 50m proximity +- Privacy preference: User can disable tracking at any time +- Route changes: ETA automatically recalculates based on current position + +--- + +## Staff: Shifts + +### Purpose +Comprehensive shift management including browsing available shifts (marketplace), managing assigned shifts, and viewing shift history. + +### User Stories + +#### Story 1: View My Assigned Shifts +**As a** worker +**I want to** access all shifts I'm assigned to +**So that** I can plan my schedule and track my commitments + +**Task Flow:** +1. User accesses shift management area +2. System displays "My Shifts" view by default +3. User reviews complete list of assigned/accepted shifts (chronologically sorted) +4. Each shift entry displays: + - Date and day of week + - Start and end times + - Role or position + - Location name and address + - Compensation rate + - Status (Upcoming, Confirmed, Pending) +5. User can browse all shifts +6. User can access detailed information for any shift +7. User can confirm attendance or cancel shift (if policy permits) + +**Information Required:** +- None (view-only access to assigned shifts) + +**Information Provided to User:** +- Complete inventory of assigned shifts +- Shift status indicators +- Quick action capabilities (Confirm, Cancel if allowed) + +**Edge Cases:** +- No assigned shifts: Message "No upcoming shifts. Browse available shifts in Find tab." +- Cancelled shifts: Display with "Cancelled" status +- Past shifts: May display with "View Feedback" or "View Details" capability +- Conflicting shifts: Warnings or conflict notifications + +--- + +#### Story 2: Browse and Book Available Shifts +**As a** worker +**I want to** browse available shifts in the marketplace and commit to ones that interest me +**So that** I can fill my schedule and maximize earnings + +```mermaid +graph TD + A[Start: Access Find Shifts] --> B[Load Available Shifts] + B --> C[Display Shift Inventory] + C --> D{User Action?} + D -->|Filter| E[Select Job Type Filter] + E --> F[Apply Filter] + F --> C + D -->|Search| G[Provide Search Query
Location or keyword] + G --> H[Apply Search] + H --> C + D -->|View Shift| I[Select Shift] + I --> J[Open Shift Details] + J --> K[Verify Profile Status] + K --> L{Profile Complete?} + L -->|No| M[Profile Completion Required
Complete profile to book shifts] + M --> N[Access Profile Completion] + L -->|Yes| O[Display Complete Shift Information
Date, Time, Location
Pay, Requirements] + O --> P{User Decision?} + P -->|Book| Q[Initiate Booking] + Q --> R[Confirm Booking] + R --> S[Submit Booking Request] + S --> T[Success: Shift Assignment Confirmed] + T --> U[View in My Shifts] + P -->|Decline| V[Return to Marketplace] + V --> C +``` + +**Task Flow:** +1. User accesses shift marketplace +2. System loads all available shifts matching user's preferences +3. User reviews shift inventory displaying: + - Date and time period + - Duration + - Role or position + - Location + - Compensation rate (hourly or flat rate) + - Distance from user's current location (if location enabled) +4. User can apply filters: + - By job type (selection from available categories) + - By search criteria (text input for location or keywords) +5. User selects specific shift for detailed review +6. System presents comprehensive shift information: + - Complete date and time details + - Venue name and complete address + - Compensation breakdown + - Required qualifications (skills needed) + - Break schedule + - Job description + - **Profile completion requirement** (if incomplete) +7. If profile complete, booking capability enabled +8. User initiates booking process +9. System requests confirmation: "Confirm booking for [Shift Name] on [Date]?" +10. User confirms booking intent +11. System processes shift assignment +12. Success confirmation: "Shift booked successfully!" +13. Shift now appears in "My Shifts" area +14. User can access My Shifts or continue browsing + +**Information Required:** +- Job type filter (selection from available options) +- Search query (text input) +- Shift selection +- Booking confirmation + +**Information Provided to User:** +- Filtered or searched shift results +- Complete shift details +- Booking confirmation dialog +- Success confirmation +- Shift assignment in My Shifts area + +**Edge Cases:** +- **Profile incomplete**: Booking disabled or hidden; message displayed: "Complete your profile to book shifts" with profile access link +- **Shift capacity reached**: Error message "This shift has been filled. Try another." +- **Schedule conflict**: Warning "You have another shift at this time. Booking will create a conflict." +- **No matching shifts**: Empty state with "No shifts match your criteria" and filter reset capability +- **Distance consideration**: Warning for distant shifts but booking still permitted +- **Network issues**: Queue booking request for submission when connected + +--- + +#### Story 3: Decline Available Shift +**As a** worker +**I want to** remove shifts I'm not interested in from my view \n**So that** I can focus on opportunities that better match my preferences + +**Task Flow:** +1. User reviews shift details +2. User indicates disinterest in specific shift\n3. System removes shift from user's feed or marks as declined +4. User returns to marketplace +5. Optional: System requests feedback \"Why did you decline?\" + +**Information Required:** +- Decline action confirmation +- Optional: Decline reason feedback + +**Information Provided to User:** +- Shift removed from current view +- Optional: Feedback collection interface + +**Edge Cases:** +- Declined shift may reappear if search filters change +- Frequent declines: System may adjust recommendation algorithm\n- Undo capability: Brief window to reverse decline action if available +- Too many declines: System may adjust recommendations + +--- + +#### Story 4: View Shift History +**As a** worker +**I want to** access all my past completed shifts +**So that** I can reference previous work and track my earnings history + +**Task Flow:** +1. User accesses shift history area +2. System presents chronologically ordered list of completed shifts (most recent first) +3. Each shift entry displays: + - Date + - Role or position + - Location name + - Hours worked + - Total compensation + - Status (Completed, No-Show, Cancelled) +4. User can access detailed information for any shift +5. Historical shift details may include: + - Arrival and departure timestamps + - Break duration + - Client feedback or rating (if available) +6. User can filter history by date range + +**Information Required:** +- None (view-only access to historical data) +- Optional: Date range filter + +**Information Provided to User:** +- Complete inventory of past shifts +- Total earnings over selected period +- Detailed information for individual shifts + +**Edge Cases:** +- No history available: Message "No completed shifts yet" with encouragement +- Disputed shift: Status indicator showing "Under Review" +- Multiple pages: Progressive loading or pagination for extensive history + +--- + +## Staff: Availability + +### Purpose +Allow workers to set their weekly availability, indicating which days and times they are free to work. This helps the system match workers to appropriate shifts. + +### User Stories + +#### Story 1: Set Weekly Availability +**As a** worker +**I want to** indicate which days of the week I'm available to work +**So that** I only receive shift offers matching my schedule + +**Task Flow:** +1. User accesses availability management +2. System displays current week (Monday-Sunday) +3. For each day, information presented: + - Day name (e.g., "Monday") + - Date + - Availability status control (available/unavailable) + - Optional: Specific time slot controls (if granular availability enabled) +4. User adjusts availability status for each day +5. System automatically saves changes (optimistic updates) +6. Brief confirmation: "Availability saved" + +**Information Required:** +- Day availability status (7 day selections) +- Optional: Time slot availability within each day + +**Information Provided to User:** +- Confirmation of status changes +- Automatic save confirmation +- Updated availability reflected in shift matching algorithm + +**Edge Cases:** +- All days marked unavailable: Warning "No availability set. You won't receive shift offers." +- Changes during system loading: Queue changes for application after load completion +- Network issues: Local changes preserved and synchronized when connected + +--- + +#### Story 2: Use Quick Availability Presets +**As a** worker +**I want to** quickly apply common availability patterns +**So that** I don't need to configure each day individually + +**Task Flow:** +1. User sees quick preset options: + - "All Week" - All 7 days available + - "Weekdays Only" - Monday-Friday available + - "Weekends Only" - Saturday-Sunday available + - "Clear All" - All days unavailable +2. User selects desired preset +3. System applies pattern to all day availability settings +4. Changes automatically saved +5. User can manually adjust individual days after applying preset + +**Information Required:** +- Preset selection + +**Information Provided to User:** +- Availability settings updated to match preset +- Automatic save confirmation + +**Edge Cases:** +- Current settings already match preset: Confirmation displayed even without changes +- Manual adjustments after preset: Overrides preset for specific days + +--- + +#### Story 3: Set Availability for Future Weeks +**As a** worker +**I want to** configure my availability for upcoming weeks +**So that** I can plan ahead and indicate future unavailability (vacation, etc.) + +**Task Flow:** +1. User sees week navigation controls +2. User navigates forward to view next week +3. Week view updates to display selected week's dates +4. User configures availability for that week using standard controls or presets +5. User can continue navigating to additional future weeks +6. User can return to current week at any time + +**Information Required:** +- Week navigation (forward/backward) +- Day availability settings for each week + +**Information Provided to User:** +- Week view updates to selected timeframe +- Availability settings saved per week +- Week identifier display (e.g., "Week of March 10") + +**Edge Cases:** +- Future range limit: May restrict to 4-8 weeks ahead +- Past weeks: Cannot edit historical weeks (read-only or hidden) +- Current week changes: Immediate effect on shift matching + +--- + +## Staff: Payments + +### Purpose +Track earnings, view payment history, and access early pay options. Workers can see their financial data and request faster payment when needed. + +### User Stories + +#### Story 1: View Earnings Summary +**As a** worker +**I want to** access my current balance and earnings trends over time +**So that** I understand my earned income and payment schedule + +**Task Flow:** +1. User accesses payments and earnings area +2. System presents financial overview displaying: + - **Balance Information**: + - Total account balance (prominently displayed) + - Amount available for early payment access + - Next scheduled payout date + - **Earnings Trend Analysis**: + - Earnings data over time + - Selectable time periods (Day, Week, Month) + - **Payment History Preview**: + - Recent transaction summary +3. User reviews financial summary information + +**Information Required:** +- None (view-only access to financial data) + +**Information Provided to User:** +- Current balance amount +- Earnings trend analysis +- Payment history preview + +**Edge Cases:** +- No earnings yet: Display $0.00 with message "Complete shifts to start earning" +- Negative balance: Alert indication if deductions exceed earnings +- Pending payments: Clear indication of amounts in processing + +--- + +#### Story 2: View Payment History +**As a** worker +**I want to** access detailed records of all my payments and withdrawals +**So that** I can track my complete financial transaction history + +**Task Flow:** +1. User navigates to Payment History section or accesses complete history +2. System presents comprehensive transaction list displaying: + - Date and time + - Transaction description (Shift payment, Early pay, ATM withdrawal) + - Amount (positive for deposits, negative for withdrawals) + - Status (Completed, Pending, Failed) + - Payment method (Direct deposit, Early pay, etc.) +3. User can apply filters: + - Time period (Day, Week, Month) + - Transaction type (All, Deposits, Withdrawals) +4. User can access detailed information for any transaction + +**Information Required:** +- Optional: Period filter selection +- Optional: Transaction type filter +- Transaction selection for details + +**Information Provided to User:** +- Filtered transaction inventory +- Detailed information for individual transactions + +**Edge Cases:** +- No transaction history: Message "No payment history yet" +- Failed transaction: Display with error indicator and explanation +- Large history: Progressive loading or pagination mechanism + +--- + +#### Story 3: Request Early Payment +**As a** worker +**I want to** request early access to my earned but not yet paid balance +**So that** I can access funds immediately when needed + +**Task Flow:** +1. User accesses early payment capability +2. System presents early payment option displaying: + - Available balance for early access (e.g., $340.00) + - Fee information (if applicable) + - Processing timeframe (e.g., "Instant" or "Within 1 hour") +3. User specifies amount to request: + - Amount input (currency) + - Cannot exceed available balance +4. User selects payment destination: + - Bank account (if registered) + - Debit card (if supported) +5. User reviews transaction summary: + - Requested amount + - Processing fee (if any) + - Net amount to receive + - Destination account (masked last 4 digits) +6. User confirms early payment request +7. System processes request +8. Success confirmation: "Early pay request submitted. Funds arriving soon!" +9. Transaction appears in payment history with "Pending" status + +**Information Required:** +- Amount to request (currency value) +- Payment destination selection +- Transaction confirmation + +**Information Provided to User:** +- Available balance for early access +- Fee calculation and disclosure +- Success confirmation +- Updated balance reflecting request +- New transaction in history + +**Edge Cases:** +- **Insufficient balance**: Error "Not enough earned balance for early pay" +- **No registered account**: Prompt "Add a bank account to use early pay" with profile navigation +- **Minimum amount requirement**: Error "Minimum early pay amount is $20" +- **Daily limit reached**: Error "You've reached your daily early pay limit. Try again tomorrow." +- **Fee disclosure**: Clear presentation of all fees before confirmation +- **Network issues**: Queue request for submission when connected + +--- + +## Staff: Profile + +### Purpose +Central hub for worker's personal information, profile completion tracking, reliability score, and navigation to profile sections (onboarding, compliance, finances, support). + +### User Stories + +#### Story 1: View Profile Overview +**As a** worker +**I want to** access my profile information and completion status +**So that** I understand requirements and how clients evaluate my reliability + +**Task Flow:** +1. User accesses profile area +2. System presents profile overview displaying: + - **Profile Header Information**: + - Profile picture + - Full name + - Reliability score (0-5 stars or percentage) + - **Reliability Statistics**: + - Total shifts completed (count) + - On-time arrival percentage + - Cancellation count + - **Profile Completion Status** (4 categories): + - Onboarding (Personal info, experience, preferences) + - Compliance (Tax forms, documents, certificates) + - Finances (Bank account, payment info) + - Support (FAQs, privacy settings) + - Each section displaying: + - Section name + - Completion percentage (e.g., "75% complete") + - Outstanding items (e.g., "2 items remaining") + - Access to continue completion +3. User reviews overview information +4. User can access any section for completion + +**Information Required:** +- None (view-only access to profile data) + +**Information Provided to User:** +- Complete profile information display +- Reliability score and detailed statistics +- Completion status for all sections +- Navigation to all profile sections + +**Edge Cases:** +- Profile 100% complete: Success indicator "Your profile is complete!" +- Low reliability score: Tips for improvement displayed +- Critical items missing: Alert "Complete [Section] to unlock full access" +- First-time view: Guidance on completing essential sections first + +--- + +#### Story 2: Navigate to Profile Sections +**As a** worker +**I want to** easily access different parts of my profile to complete or update information +**So that** I can maintain an accurate and complete profile + +**Task Flow:** +1. Worker reviews profile overview +2. Worker selects a profile section (Onboarding, Compliance, Finances, or Support) +3. System loads that section's data and features +4. Worker completes tasks within that section (see Profile Sections stories) +5. Worker returns to profile overview +6. Completion percentage recalculates to reflect changes + +**Information Required:** +- Section selection (Onboarding, Compliance, Finances, or Support) + +**Information Provided to User:** +- Selected section data and available actions +- Updated completion status after returning + +--- + +#### Story 3: View Reliability Score Details +**As a** worker +**I want to** understand how my reliability score is calculated +**So that** I know how to improve it and understand how clients evaluate me + +**Task Flow:** +1. Worker views reliability score on profile +2. Worker requests detailed score information +3. System provides score breakdown showing: + - Score breakdown (factors: on-time arrivals, completions, cancellations, client ratings) + - How each factor impacts score + - Tips for improvement +4. Worker reviews information +5. Worker dismisses details and returns to profile + +**Information Required:** +- Request for reliability score details + +**Information Provided to User:** +- Score breakdown by factor (on-time arrivals, completions, cancellations, client ratings) +- Factor impact explanations +- Improvement suggestions + +--- + +#### Story 4: Sign Out from Profile +**As a** worker +**I want to** sign out of my account +**So that** my information is secure when I'm not using the app + +**Task Flow:** +1. Worker navigates to sign out option in profile +2. Worker initiates sign out action +3. System requests sign out confirmation: "Are you sure you want to sign out?" +4. Worker confirms sign out +5. System terminates user session +6. System returns worker to authentication state + +**Information Required:** +- Sign out initiation +- Confirmation of sign out intent + +**Information Provided to User:** +- Sign out confirmation request +- Session termination confirmation +- Return to authentication state + +**Edge Cases:** +- Cancel confirmation: Session remains active, worker returns to profile + +--- + +## Staff: Profile Sections + +### Purpose +Detailed sub-features for completing different aspects of a worker's profile. Organized into 4 categories: Onboarding, Compliance, Finances, and Support. + +### Categories +1. **Onboarding** - Personal info, experience, emergency contacts, attire +2. **Compliance** - Tax forms, identity documents, certificates +3. **Finances** - Bank account setup, timecard management +4. **Support** - FAQs, privacy & security settings + +--- + +### Onboarding Section + +#### Story 1: Complete Personal Information +**As a** worker +**I want to** provide my personal details +**So that** clients can identify me and I can receive important communications + +**Task Flow:** +1. Worker accesses Onboarding → Profile Info section +2. System presents required personal information fields: + - Full name (may be pre-filled from signup) + - Date of birth + - Email address + - Secondary contact information + - Profile photo + - Language preference + - Preferred work locations +3. Worker provides or updates information +4. Worker supplies profile photo: + - Worker provides photo via camera capture or existing photo + - Worker adjusts/crops photo if needed + - Worker confirms photo selection +5. Worker submits information +6. System validates data and persists changes +7. System confirms: "Profile information updated" + +**Information Required:** +- Full name +- Date of birth +- Email address +- Secondary contact information +- Profile photo (image file) +- Language preference +- Preferred work locations + +**Information Provided to User:** +- Validation feedback for each field +- Success confirmation +- Updated profile data + +**Edge Cases:** +- Invalid email: Validation error message +- Age under 18: May require additional verification +- Photo too large: Compression applied or size error message + +--- + +#### Story 2: Document Work Experience +**As a** worker +**I want to** list my work history and skills +**So that** clients see my qualifications and I'm matched to appropriate jobs + +**Task Flow:** +1. Worker accesses Onboarding → Experience section +2. System displays existing experience entries (if any) +3. Worker initiates adding new experience entry +4. System requests experience details: + - Job title/role + - Years of experience + - Skills (Server, Cook, Driver, etc.) + - References (optional) +5. Worker provides experience information +6. Worker submits entry +7. System adds experience to worker's profile +8. Worker can add multiple entries +9. Worker can modify or remove entries + +**Information Required:** +- Job title/role +- Years of experience (numeric) +- Skills (multi-select: Server, Cook, Driver, etc.) +- References (optional) + +**Information Provided to User:** +- List of all experience entries +- Success confirmation for each operation + +**Edge Cases:** +- No experience: Worker can skip or indicate "Entry Level" +- Maximum entries: System may limit to 5-10 entries + +--- + +#### Story 3: Add Emergency Contact +**As a** worker +**I want to** provide emergency contact information +**So that** someone can be reached if something happens while I'm working + +**Task Flow:** +1. Worker accesses Onboarding → Emergency Contact section +2. System displays existing contacts (if any) +3. Worker initiates adding new contact +4. System requests contact details: + - Full name + - Relationship (Spouse, Parent, Sibling, Friend) + - Phone number +5. Worker provides contact information +6. Worker submits contact +7. System adds contact to worker's profile +8. Worker can add multiple contacts (primary, secondary) + +**Information Required:** +- Contact full name +- Relationship type (Spouse, Parent, Sibling, Friend) +- Phone number + +**Information Provided to User:** +- List of all emergency contacts +- Success confirmation + +**Edge Cases:** +- Required for profile completion +- Workers can designate primary contact + +--- + +#### Story 4: Upload Attire Photo +**As a** worker +**I want to** upload photos showing my work attire +**So that** clients can verify I meet dress code requirements + +**Task Flow:** +1. Worker accesses Onboarding → Attire section +2. System provides instructions: "Take photos of yourself in appropriate work attire" +3. Worker initiates photo submission +4. Worker provides photo via camera capture or existing photo +5. System displays photo preview +6. Worker confirms photo or provides alternative +7. System stores photo in worker's profile +8. Worker can submit multiple photos (front view, full body, etc.) + +**Information Required:** +- Attire photo (image file from camera or existing photo) +- Photo confirmation + +**Information Provided to User:** +- Uploaded photos display +- Success confirmation +- Photo requirements (e.g., "Full body, professional attire") + +**Edge Cases:** +- Photo requirements stated (e.g., "Full body, professional attire") +- Photos may require admin approval + +--- + +### Compliance Section + +#### Story 5: Upload Tax Forms +**As a** worker +**I want to** upload required tax documentation +**So that** I'm legally compliant and can receive payment + +```mermaid +graph TD + A[Start: Navigate to Compliance - Tax Forms] --> B[View Required Forms List] + B --> C{Forms Uploaded?} + C -->|No| D[See Required Forms
W-4, W-9, State Tax] + C -->|Yes| E[See Uploaded Status
Completed Status Shown] + D --> F[Tap Upload Form Button] + F --> G{Choose Upload Method} + G -->|Camera| H[Open Camera
Capture Document] + G -->|Gallery| I[Open Gallery
Select Existing Photo] + G -->|File| J[Open File Picker
Select PDF] + H --> K[Preview Captured Image] + I --> K + J --> K + K --> L{Image Clear?} + L -->|No| M[Retake or Choose Different] + M --> G + L -->|Yes| N[Confirm Upload] + N --> O[System Processes
OCR/Validation] + O --> P{Valid Document?} + P -->|No| Q[Show Error
Please upload correct form] + Q --> F + P -->|Yes| R[Success: Form Uploaded] + R --> S[Status Changes to Pending Review] + S --> T[Admin Reviews if Required] + T --> U{Approved?} + U -->|Yes| V[Status: Verified] + U -->|No| W[Status: Rejected - Reason Shown] + W --> F +``` + +**Task Flow:** +1. Worker accesses Compliance → Tax Forms section +2. System displays list of required forms: + - W-4 (Federal withholding) + - W-9 (Tax identification) + - State tax forms (if applicable) +3. System shows status for each form: + - Not uploaded + - Pending review + - Approved +4. Worker selects form to upload and provides document: + - Via camera capture + - Via existing photo + - Via PDF file selection +5. Worker captures or selects document +6. System displays document preview +7. Worker confirms submission +8. System performs basic validation (document type, clarity) +9. System confirms: "Tax form uploaded. Pending review." +10. Status changes to "Pending Review" +11. Admin reviews and approves/rejects +12. Worker receives approval status update + +**Information Required:** +- Tax form document (image or PDF) +- Document confirmation + +**Information Provided to User:** +- Upload progress +- Success confirmation +- Form status (Not uploaded, Pending, Approved, Rejected) +- Approval/rejection notifications + +**Edge Cases:** +- **Blurry photo**: System may reject or warn "Document not clear. Please retake." +- **Wrong form**: Validation error "This doesn't appear to be a W-4 form" +- **Rejected by admin**: User receives notification with reason and option to re-upload +- **Signature required**: Form may require digital signature before upload +- **Expiration**: Some forms expire and require re-upload annually + +--- + +#### Story 6: Upload Identity Documents +**As a** worker +**I want to** verify my identity with required documents +**So that** I can meet compliance requirements and be eligible to work + +**Task Flow:** +1. Worker accesses Compliance → Documents section +2. System displays required documents: + - Driver's license or state ID (both sides) + - Social Security Number verification + - Address verification (utility bill, lease, etc.) +3. Worker uploads each document (same process as tax forms) +4. System performs verification and routes to admin for review +5. Status updates to Approved when complete + +**Information Required:** +- ID document photos (front and back) +- Social Security Number (secure entry or document) +- Address proof document + +**Information Provided to User:** +- Upload confirmations for each document +- Verification status +- Approval notifications + +**Edge Cases:** +- SSN must be securely transmitted and encrypted +- ID expiration date: System tracks and notifies before expiry +- Address verification may require recent document (within 60 days) + +--- + +#### Story 7: Upload Professional Certificates +**As a** worker +**I want to** upload professional licenses and certifications +**So that** I can qualify for specialized shifts requiring credentials + +**Task Flow:** +1. Worker accesses Compliance → Certificates section +2. System displays optional/required certificates based on roles: + - Food Handler's Permit + - Bartending License + - Forklift Certification + - CPR/First Aid + - Background check status +3. Worker uploads applicable certificates +4. Worker provides expiration date for each certificate +5. System tracks expiration and sends renewal reminders +6. Admin reviews and approves certificates +7. System updates worker's eligible roles based on approved certificates + +**Information Required:** +- Certificate documents (photos or PDFs) +- Certificate number (optional) +- Expiration date + +**Information Provided to User:** +- List of certificates with expiration dates +- Renewal reminders (notifications) +- Newly unlocked roles + +**Edge Cases:** +- Expired certificate: Warning displayed, worker cannot accept related shifts +- Background check status: May be handled separately +- Temporary certificates: Short-term expiration dates supported + +--- + +### Finances Section + +#### Story 8: Set Up Bank Account +**As a** worker +**I want to** add my bank account information +**So that** I can receive direct deposit payments + +**Task Flow:** +1. Worker accesses Finances → Bank Account section +2. If no account on file, system presents option to add bank account +3. Worker initiates bank account setup +4. System requests account details: + - Bank name + - Account holder name + - Account number + - Routing number + - Account type (Checking or Savings) +5. Worker provides banking information +6. Worker submits details +7. System may verify account (micro-deposits or instant verification) +8. System confirms: "Bank account added" +9. System displays masked account information (last 4 digits only) + +**Information Required:** +- Bank name +- Account holder name +- Account number (secure) +- Routing number (9 digits, secure) +- Account type (Checking or Savings) + +**Information Provided to User:** +- Masked account display (e.g., "••••1234") +- Account verification status + +**Edge Cases:** +- Invalid routing number: Validation error +- Verification failed: Worker must confirm account via micro-deposits +- Multiple accounts: Workers can add backup account +- Edit or remove account: Modification and removal available + +--- + +#### Story 9: View and Dispute Timecard +**As a** worker +**I want to** view my recorded hours and dispute any errors +**So that** I'm paid correctly for time worked + +**Task Flow:** +1. Worker accesses Finances → Time Card section +2. System displays list of recent shifts with recorded hours: + - Shift date + - Check-in time + - Check-out time + - Break duration + - Total hours + - Pay amount +3. Worker selects a shift to view details +4. If hours are incorrect, worker initiates dispute +5. System requests dispute information: + - What's wrong? (multiple options or free text) + - Correct hours (manual entry) + - Notes/explanation +6. Worker submits dispute +7. System notifies client/manager +8. System tracks dispute status (Submitted, Under Review, Resolved) + +**Information Required:** +- Shift selection +- Dispute reason +- Corrected hours (numeric) +- Explanation + +**Information Provided to User:** +- Timecard details for all shifts +- Dispute submission confirmation +- Dispute status updates + +**Edge Cases:** +- Adjustment approved: Payment corrected +- Adjustment denied: Reason provided, worker can escalate +- Multiple disputes: May flag for review + +--- + +### Support Section + +#### Story 10: Access FAQs +**As a** worker +**I want to** find answers to common questions +**So that** I can resolve issues without contacting support + +**Task Flow:** +1. Worker accesses Support → FAQs section +2. System displays FAQ categories: + - Getting Started + - Shifts & Scheduling + - Payments + - Technical Issues +3. Worker selects a category +4. System displays list of frequently asked questions for that category +5. Worker selects a question +6. System displays detailed answer +7. Worker can search FAQs using text query +8. If issue not resolved, worker can contact support + +**Information Required:** +- Category selection +- Question selection +- Search query (optional) + +**Information Provided to User:** +- FAQ categories list +- Questions and answers for selected category +- Search results matching query +- Contact support option + +**Edge Cases:** +- No results for search: System shows "No matching FAQs" with Contact Support option +- Links in answers: May reference relevant sections or features + +--- + +#### Story 11: Manage Privacy & Security +**As a** worker +**I want to** control my privacy settings and account security +**So that** my personal information is protected + +**Task Flow:** +1. Worker accesses Support → Privacy & Security section +2. System presents security and privacy options: + - **Change Password**: + - Current password verification + - New password entry (with strength indicator) + - New password confirmation + - **Two-Factor Authentication**: + - Enable/disable 2FA + - Setup instructions if enabling + - **Privacy Settings**: + - Profile visibility controls + - Communication preferences + - **Data Access**: + - Download personal data (export to file) + - Delete account (requires confirmation) +3. Worker makes desired changes +4. Worker saves changes +5. System provides confirmation for each change + +**Information Required:** +- Current password (for password change) +- New password (secure) +- Password confirmation +- 2FA preference (enable/disable) +- Privacy preferences +- Data export request +- Account deletion confirmation + +**Information Provided to User:** +- Success confirmations for each change +- 2FA setup instructions +- Data export file (when ready) +- Account deletion confirmation + +**Edge Cases:** +- **Password requirements**: Minimum length, complexity rules enforced and displayed +- **2FA setup**: Requires phone or authenticator app +- **Delete account**: Multi-step confirmation with warnings about data loss +- **Data export**: May take time to prepare, delivered via email + +--- + +# Glossary + +### Client Application Terms + +- **Client**: A business owner or manager who uses the app to request staffing and manage operations. +- **Coverage**: The percentage or count of filled positions versus total positions needed for a given time period. 100% coverage means all shifts are filled. +- **Cost Center**: An accounting designation for tracking expenses by location or department within a business. +- **Hub**: A physical business location or venue where staff work (e.g., restaurant, warehouse, event venue). +- **Hub Manager**: A supervising employee at a hub location who oversees on-site operations. +- **Invoice**: A bill for services rendered, detailing worker hours, pay rates, and total costs for completed shifts. +- **NFC Tag**: Near Field Communication tag used for quick check-ins via phone tap at a physical location. +- **Order**: A staffing request created by a client specifying positions needed, dates, times, and location. + - **One-Time Order**: Single-day staffing request + - **Recurring Order**: Weekly pattern repeated over a limited period (max 29 days) + - **Permanent Order**: Ongoing staffing for certain days with no end date + - **Rapid Order**: Emergency/expedited staffing request +- **Position**: A role or job function within a shift (e.g., Server, Cook, Bartender, Warehouse Associate). +- **Vendor**: A staffing agency or organization providing workers (may be internal to KROW). + +### Staff Application Terms + +- **Auto-Match**: A feature that automatically notifies workers of shifts matching their preferences and availability. +- **Break**: A rest period during a shift, which is tracked separately and deducted from billable hours. +- **Check-In**: The action of confirming arrival at a shift location, typically with location verification. +- **Check-Out**: The action of ending a shift and recording total time worked. +- **Commute Mode**: A tracking feature showing the worker's real-time location and ETA to the venue. +- **Early Pay**: A service allowing workers to access earned wages before the regular pay date, often for a fee. +- **Geo-Fencing**: Location verification that ensures a worker is within a certain distance (500m) of the venue. +- **Marketplace**: The "Find Shifts" tab where workers browse and book available shifts. +- **OTP (One-Time Password)**: A temporary 6-digit code sent via SMS for authentication. +- **Profile Completion Gate**: A requirement that workers complete certain profile sections before they can book shifts. +- **Reliability Score**: A rating (0-5 or percentage) based on attendance, punctuality, completion rate, and client feedback. +- **Shift**: A scheduled work period with specific start/end times, location, and role. +- **Timecard**: A record of hours worked, including check-in, check-out, and break times. + +### Shared Terms + +- **Business Location**: See Hub above. +- **Role**: A job function or position type (e.g., Server, Cook, Driver). +- **Staff/Worker**: A person who accepts and performs shifts through the KROW platform. +- **Status**: The current state of an order, shift, invoice, or document (e.g., Pending, Approved, Completed, Cancelled). + +--- + +## Document End + +**Total Features Documented**: 18 (9 Client + 9 Staff) +**Total User Stories**: 60+ +**Total Mermaid Diagrams**: 4 + +This document provides a complete functional overview of the KROW Workforce Management Platform from a design perspective, enabling designers to understand user needs, flows, and interactions without needing to understand the underlying code implementation. diff --git a/docs/MILESTONES/M4/demos/m4-client-note.md b/docs/MILESTONES/M4/demos/m4-client-note.md new file mode 100644 index 00000000..550282ca --- /dev/null +++ b/docs/MILESTONES/M4/demos/m4-client-note.md @@ -0,0 +1,82 @@ +# KROW Workforce Platform — M4 Guide + +**Version:** Milestone 4 (0.0.1-IlianaStaffM4 and 0.0.1-IlianaClientM4) +**Estimated Duration:** 25-30 minutes + +--- + +## 📦 Deliverables + +- **Client Mobile Application** (v0.0.1-IlianaClientM4) +- **Staff Mobile Application** (v0.0.1-IlianaStaffM4) +- **Full Demo Video** - Comprehensive walkthrough of all (M1 - M4) completed features of the mobile applications. + +--- + +## 1. Overview + +### Core Improvements +M4 delivers three key areas of improvement: + +1. **Overall Application Improvements** + - Auth session persistence: Users stay signed in after reopening the app + - Stability fixes from M3 client feedback and dev team discoveries + - UI/UX improvements across key screens for clarity and speed + +2. **Client App Updates** + - Complete order creation flow (Rapid, Permanent, Recurring orders) + - Shift manager assignment support + - Paid/unpaid break handling in orders + - Complete Reports section (Daily Ops, Spend, Coverage, No-show, Performance) + - Cost centres in hubs for location/business unit tracking + - Billing approval workflow for pending bills + +3. **Staff App Updates** + - Profile completion requirements gating payments and clockings + - Worker benefits integration + - Enhanced shift discovery with filtering by location + - Spanish localization support + - AI-verified document uploads (Attire, Documents, Certificates) + - FAQ and Privacy Policy + - Worker profile visibility controls + +--- + +## 2. Required Test Accounts + +**Client Account (Business User):** +- Email: `legendary@krowd.com` +- Password: `Demo2026!` +- Client Name: "KROW" + +**Staff Account (Worker):** +- Phone: `+15557654321` +- OTP Code: `123456` (testing mode) +- Name: "Mariana Torres" + +***Note on Profile Completion*** +When a staff user hasn't completed their profile, they see an empty/incomplete state on their home screen. Currently tracked sections to mark as complete: +- Profile Information (full name, email, phone, preferred locations) +- Emergency Contact + +Future sections can be added as mandatory, such as Tax Forms, Bank Account, Documents, Certificates, and Attires. + +***Profile Blocking Rules*** +When the profile is incomplete, the following features are blocked to encourage completion: +- Clock-in page is hidden +- Payments are blocked +- "My Shifts" and History sections are hidden +- Users can view available shifts but cannot book them + +This ensures we have all necessary information for compliance and payroll before workers are allowed to work. + +--- + +## 3. M4 Key Deliverables + +✅ Stronger reliability and stability +✅ Completed client ordering and reporting workflows +✅ Better profile and shift tooling for staff +✅ AI-assisted document verification +✅ Localization support (Spanish) +✅ Improved billing and cost tracking controls diff --git a/docs/MILESTONES/M5/planning/m5-client-clarifications.md b/docs/MILESTONES/M5/planning/m5-client-clarifications.md new file mode 100644 index 00000000..a1081392 --- /dev/null +++ b/docs/MILESTONES/M5/planning/m5-client-clarifications.md @@ -0,0 +1,95 @@ +# Clarifications Required – Project Discovery + +During Milestone 4 (M5) planning, we identified several items that require clarification before development can proceed. + +Please review the questions below and share any relevant documents, examples, or preferences. + +--- + +## Issue: Research: Validate worker SSN number in the US + +### Description +We need to identify a viable approach/service to validate a worker’s SSN for the US market, compare 2–3 options, and recommend one with cost and risk considerations. + +### Existing Tools / Platforms +- Are you currently using any service or process to validate SSNs (in the legacy app or elsewhere)? If yes, which tool(s) and what’s working/not working today? + +--- + +## Issue: Research: Validate worker bank account details in the US + +### Description +We need to identify a reliable, server-side way to validate worker bank account details for the US market (beyond basic UI checks), compare 2–3 options, and recommend one. + +### Existing Tools / Platforms +- Do you currently use any tool/process for bank account validation or payouts? If yes, which platform(s) ? + +--- + +## Issue: Research: Select payment platform for worker payouts + +### Description +We need to select a payout/payment platform to pay workers, comparing options by cost, reliability, and integration effort, and then recommend a platform? + +### Existing Tools / Platforms +- Are you already using a payments/payout platform today (or do you have a preferred vendor relationship)? + +--- + +## Issue: Business: Create template model for PDF reports + +### Description +We need to align PDF report formats across the client mobile and web platforms by defining a shared template model that’s ready to implement in both. + +### Existing Tools / Platforms +- Do you already have existing PDF templates (or examples) you use today? If yes, can you share them and how they’re currently produced? + +--- + +## Issue: Business: Finalize terms of service and privacy policy for mobile apps + +### Description +We need approved Terms of Service and Privacy Policy documents for mobile apps. + +### Clarifications Needed +1. Do you already have Terms of Service and a Privacy Policy draft we should implement? + +--- + +## Issue: Business: Handle worker data requests + +### Description +We need a documented workflow to handle worker requests for their personal data, covering intake, identity verification, fulfillment, and timelines. + +### Clarifications Needed +1. How should requests be submitted (in-app, email, support form, other)? +2. What verification steps are required before fulfilling a request? + +### Existing Tools / Platforms +- Do you currently use a support/ticketing system or internal workflow for these requests? + +--- + +## Issue: Business: Finalize key terminology used in the app + +### Description +We need consistent, accurate product language across the app. + +### Clarifications Needed +1. The "staff app" shall we call it “Worker App” or “Worker Mobile App” (or another term)? +2. For worker registration, should we use “signup” or “onboarding” (or another term)? + +--- + +## Issue: RESEARCH: How to calculate the reliability score of a worker + +### Description +We need to define the formula and logic for a worker’s reliability score, including which signals feed the score, its scale, display expectations, and its relationship to star rating. + +### Clarifications Needed +1. Is the reliability score given by clients, system-derived, or a combination? +--- + +## Additional Context + +If there are documents, workflows, screenshots, examples, policy notes, or tools related to these topics, please share them. Even rough notes are helpful — they will help us confirm requirements, choose the right integrations, and design the best user experience before implementation begins. diff --git a/docs/MOBILE/00-agent-development-rules.md b/docs/MOBILE/00-agent-development-rules.md index b28a9cb2..6049b64b 100644 --- a/docs/MOBILE/00-agent-development-rules.md +++ b/docs/MOBILE/00-agent-development-rules.md @@ -92,7 +92,7 @@ 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`. + * 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/02-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**. @@ -103,7 +103,7 @@ 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. + - For design system related questions, refer to `apps/mobile/docs/02-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'?"). diff --git a/docs/MOBILE/01-architecture-principles.md b/docs/MOBILE/01-architecture-principles.md index 564df8e2..ae34bb58 100644 --- a/docs/MOBILE/01-architecture-principles.md +++ b/docs/MOBILE/01-architecture-principles.md @@ -105,11 +105,11 @@ graph TD - 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`. + - More details on how to use this package is available in the `apps/mobile/docs/02-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`. + - More details on how to use this package is available in the `apps/mobile/docs/02-design-system-usage.md`. ### 2.6 Core Localization (`apps/mobile/packages/core_localization`) - **Role**: Centralized language and localization management. diff --git a/docs/MOBILE/04-use-case-completion-audit.md b/docs/MOBILE/04-use-case-completion-audit.md index d17b86a0..5825311f 100644 --- a/docs/MOBILE/04-use-case-completion-audit.md +++ b/docs/MOBILE/04-use-case-completion-audit.md @@ -1,9 +1,10 @@ # 📊 Use Case Completion Audit -**Generated:** 2026-03-02 +**Generated:** 2026-03-06 **Auditor Role:** System Analyst / Flutter Architect **Source of Truth:** `docs/ARCHITECTURE/client-mobile-application/use-case.md`, `docs/ARCHITECTURE/staff-mobile-application/use-case.md` -**Codebase Checked:** `apps/mobile/packages/features/` and `apps/mobile/apps/` (actual production apps) +**Codebase Checked:** `apps/mobile/packages/features/` and `apps/mobile/apps/` (actual production apps) +**Latest Milestone:** M4 (released 2026-03-05) --- @@ -162,7 +163,7 @@ | 2.1 Browse & Filter Jobs | Filter by Distance | ✅ | ✅ Completed | Distance/radius filtering implemented in shifts module. | | 2.1 Browse & Filter Jobs | View job card details | ✅ | ✅ Completed | Comprehensive job cards with pay, location, requirements. | | 2.3 Set Availability | Select dates/times → Save preferences | ✅ | ✅ Completed | `availability_page.dart` + AvailabilityBloc with 3 use cases. | -| View Benefits | Browse available benefits | ✅ | 🚫 Completed | `benefits_overview_page.dart` (454 lines) fully implemented as part of home module. | +| View Benefits | Browse available benefits | ✅ | ✅ Completed | `benefits_overview_page.dart` in home module. Documented in M4 milestone. | | Upcoming Shift Quick-Link | Next shift banner on home | ✅ | 🚫 Completed | Upcoming shifts display on worker home page. | --- @@ -215,18 +216,18 @@ | Use Case | Sub-Use Case | Production App | Status | Notes | |:---|:---|:---:|:---:|:---| | 5.1 Manage Compliance Documents | Navigate to Compliance Menu | ✅ | ✅ Completed | Compliance section in `staff_profile_page.dart`. | -| 5.1 Manage Compliance Documents | Upload Certificates | ✅ | ✅ Completed | `certificates/` module with 4 use cases + 2 pages. | -| 5.1 Manage Compliance Documents | View/Manage Identity Documents | ✅ | ✅ Completed | `documents/` module with upload + view functionality. | -| 5.2 Manage Tax Forms | Complete W-4 digitally & submit | ✅ | ✅ Completed | `tax_forms/form_w4_page.dart` + FormW4Cubit + use cases. | -| 5.2 Manage Tax Forms | Complete I-9 digitally & submit | ✅ | ✅ Completed | `tax_forms/form_i9_page.dart` + FormI9Cubit + use cases. | -| 5.4 Account Settings | Update Bank Details | ✅ | ✅ Completed | `staff_bank_account/` module with page + cubit. | -| 5.4 Account Settings | Access Support / FAQs | ✅ | ✅ Completed | `faqs/` module with search functionality + 2 use cases. | -| Personal Info Management | Update profile information | ✅ | 🚫 Completed | `profile_info/` module with 3 pages (personal info, language, locations). | -| Emergency Contact | Manage emergency contacts | ✅ | 🚫 Completed | `emergency_contact/` module with get + save use cases. | -| Experience Management | Update industries and skills | ✅ | 🚫 Completed | `experience/` module with 3 use cases. | -| Attire Management | Upload attire photos | ✅ | 🚫 Completed | `attire/` module with upload + photo management. | -| Timecard Viewing | View clock-in/out history | ✅ | 🚫 Completed | `time_card/` module with get_time_cards use case. | -| Privacy & Security | Manage privacy settings | ✅ | 🚫 Completed | `privacy_security/` module with 4 use cases + 2 pages. | +| 5.1 Manage Compliance Documents | Upload Certificates | ✅ | ✅ Completed | `profile_sections/compliance/certificates/` module with 4 use cases + 2 pages. M4 feature. | +| 5.1 Manage Compliance Documents | View/Manage Identity Documents | ✅ | ✅ Completed | `profile_sections/compliance/documents/` module with camera/gallery upload. M4 feature. | +| 5.2 Manage Tax Forms | Complete W-4 digitally & submit | ✅ | ✅ Completed | `profile_sections/finances/tax_forms/form_w4_page.dart` + FormW4Cubit + use cases. | +| 5.2 Manage Tax Forms | Complete I-9 digitally & submit | ✅ | ✅ Completed | `profile_sections/finances/tax_forms/form_i9_page.dart` + FormI9Cubit + use cases. | +| 5.4 Account Settings | Update Bank Details | ✅ | ✅ Completed | `profile_sections/finances/staff_bank_account/` module with page + cubit. | +| 5.4 Account Settings | Access Support / FAQs | ✅ | ✅ Completed | `profile_sections/support/faqs/` module with search functionality + 2 use cases. | +| Personal Info Management | Update profile information | ✅ | ✅ Completed | `profile_sections/onboarding/profile_info/` module with 3 pages. Documented in M4. | +| Emergency Contact | Manage emergency contacts | ✅ | ✅ Completed | `profile_sections/onboarding/emergency_contact/` module. Documented in M4. | +| Experience Management | Update industries and skills | ✅ | ✅ Completed | `profile_sections/onboarding/experience/` module with 3 use cases. Documented in M4. | +| Attire Management | Upload attire photos & verification | ✅ | ✅ Completed | `profile_sections/compliance/attire/` module with camera/gallery support. Documented in M4. | +| Timecard Viewing | View clock-in/out history | ✅ | 🚫 Completed | `profile_sections/finances/time_card/` module with get_time_cards use case. | +| Privacy & Security | Manage privacy settings & visibility | ✅ | ✅ Completed | `profile_sections/support/privacy_security/` module with 4 use cases + 2 pages. Documented in M4. | --- @@ -235,7 +236,6 @@ | Feature | Status | Notes | |:---|:---:|:---| | 5.3 KROW University Training | ❌ Missing | No training module exists. Module, video/quiz functionality not implemented. | -| 5.4 View Benefits | ✅ **Actually Implemented** | Found in home module as `benefits_overview_page.dart` (454 lines). | | In-App Support Chat | ❌ Missing | No messaging module (only push notification support). | | Leaderboard | ❌ Missing | No competitive tracking/gamification module. | @@ -269,7 +269,7 @@ - ✅ Clock In/Out (GPS + NFC): 100% - ✅ Payments & Early Pay: 100% - ✅ Availability: 100% -- ✅ Profile & Compliance: 100% (11 subsections) +- ✅ Profile & Compliance: 100% (13 subsections via modular `profile_sections` structure) - ❌ KROW University: 0% (training module not implemented) --- @@ -292,7 +292,12 @@ - **Presentation** (pages, widgets, BLoCs) - **Domain** (use cases, entities) - **Data** (repositories, models, data sources) - - **Dependency injection** via GetIt + - **Dependency injection** via Flutter Modular +- **Modular Profile Sections** (M4): Staff profile features organized in `profile_sections/` with 4 sub-modules: + - `onboarding/` - Profile info, experience, emergency contacts + - `compliance/` - Documents, certificates, attire + - `finances/` - Bank account, tax forms, timecard + - `support/` - FAQs, privacy & security ### Known Technical Debt - **Coverage Re-post**: Mutation exists but noted as stub in code (needs backend wiring) diff --git a/docs/MOBILE/05-release-process.md b/docs/MOBILE/05-release-process.md new file mode 100644 index 00000000..7749745b --- /dev/null +++ b/docs/MOBILE/05-release-process.md @@ -0,0 +1,64 @@ +# Mobile Release Process + +**For complete release documentation, see: [docs/RELEASE/mobile-releases.md](../RELEASE/mobile-releases.md)** + +--- + +## Quick Links + +### Release Workflows +- **Product Release**: Trigger at: [GitHub Actions](https://github.com/Oloodi/krow-workforce/actions/workflows/product-release.yml) +- **Hotfix Creation**: Trigger at: [GitHub Actions](https://github.com/Oloodi/krow-workforce/actions/workflows/hotfix-branch-creation.yml) + +### Key Concepts + +**Versioning**: We use semantic versioning with milestone suffixes (e.g., `0.0.1-m4`) +- Defined in: `apps/mobile/apps/staff/pubspec.yaml` or `apps/mobile/apps/client/pubspec.yaml` +- Auto-extracted by workflows (no manual input required) + +**CHANGELOGs**: +- Staff: `apps/mobile/apps/staff/CHANGELOG.md` +- Client: `apps/mobile/apps/client/CHANGELOG.md` +- Format: `## [v0.0.1-m4] - Milestone 4 - 2026-03-05` + +**Git Tags**: `krow-withus--mobile/-vX.Y.Z` +- Example: `krow-withus-worker-mobile/dev-v0.0.1-m4` + +--- + +## Quick Start + +### Standard Release + +1. **Update CHANGELOG** with user-facing changes +2. **Update version** in `pubspec.yaml` +3. **Commit and push** to dev branch +4. **Trigger workflow**: + - Go to GitHub Actions → "📦 Product Release" + - Select app (worker/client) and environment (dev/stage/prod) + - Click "Run workflow" + +### Hotfix Release + +1. **Trigger workflow**: + - Go to GitHub Actions → "🚨 Product Hotfix - Create Branch" + - Enter current production version and issue description + - Workflow creates branch and updates version/CHANGELOG +2. **Fix bug** on hotfix branch +3. **Merge to main** and release to production + +--- + +## For Complete Details + +See the comprehensive documentation: **[docs/RELEASE/mobile-releases.md](../RELEASE/mobile-releases.md)** + +This includes: +- ✅ Detailed versioning strategy +- ✅ CHANGELOG format guidelines +- ✅ Step-by-step release procedures +- ✅ APK signing setup (24 GitHub Secrets) +- ✅ Helper scripts reference +- ✅ Hotfix process +- ✅ Troubleshooting guide +- ✅ Release cadence (dev/stage/prod) diff --git a/docs/RELEASE/mobile-releases.md b/docs/RELEASE/mobile-releases.md new file mode 100644 index 00000000..4403630c --- /dev/null +++ b/docs/RELEASE/mobile-releases.md @@ -0,0 +1,727 @@ +# Mobile App Release Process + +**For Staff Mobile & Client Mobile Apps** + +**Document Version**: 2.0 +**Last Updated**: 2026-03-06 +**Status**: ✅ Production Ready + +--- + +## 📱 Overview + +This document covers the complete release process for both mobile applications: + +- **Staff Mobile App** (Worker Mobile) - `krow-withus-worker-mobile` +- **Client Mobile App** - `krow-withus-client-mobile` + +Both apps: +- Built with Flutter +- Distributed via iOS App Store & Google Play Store +- Maintain independent versions +- Have independent CHANGELOGs +- Released via GitHub Actions workflows + +--- + +## 📐 Versioning Strategy + +### Semantic Versioning with Milestones + +We use **Semantic Versioning 2.0.0** with milestone suffixes: + +``` +MAJOR.MINOR.PATCH-milestone +``` + +**Examples:** +- `0.0.1-m3` - Milestone 3 release +- `0.0.1-m4` - Milestone 4 release +- `1.0.0` - First production release (no suffix) +- `1.0.1` - Production patch release + +**Version Rules:** +- **MAJOR**: Breaking changes, major architectural updates +- **MINOR**: New features, backward-compatible changes +- **PATCH**: Bug fixes, minor improvements +- **SUFFIX**: `-m3`, `-m4`, etc. for milestone tracking + +### Version Location + +Versions are defined in `pubspec.yaml`: + +**Staff Mobile:** `apps/mobile/apps/staff/pubspec.yaml` +```yaml +version: 0.0.1-m4+1 +``` + +**Client Mobile:** `apps/mobile/apps/client/pubspec.yaml` +```yaml +version: 0.0.1-m4+1 +``` + +**Format:** `X.Y.Z-suffix+buildNumber` +- `0.0.1-m4` = version with milestone +- `+1` = build number (for app stores) + +### Version Auto-Extraction + +GitHub Actions workflows automatically extract the version from `pubspec.yaml` using `.github/scripts/extract-version.sh`. **No manual version input required.** + +--- + +## 📝 CHANGELOG Management + +### File Locations + +- **Staff Mobile:** `apps/mobile/apps/staff/CHANGELOG.md` +- **Client Mobile:** `apps/mobile/apps/client/CHANGELOG.md` + +### Format Standard + +```markdown +# [App Name] - Change Log + +## [v0.0.1-m4] - Milestone 4 - 2026-03-05 + +### Added - [Category Name] +- Feature description +- Another feature + +### Fixed +- Bug fix description + +### Changed +- Changed behavior + +--- + +## [v0.0.1-m3] - Milestone 3 - 2026-02-15 + +### Added - [Category Name] +... +``` + +### Section Guidelines + +Use these standard categories: +- **Added**: New features +- **Fixed**: Bug fixes +- **Changed**: Changes to existing functionality +- **Deprecated**: Soon-to-be removed features +- **Removed**: Removed features +- **Security**: Security fixes + +### When to Update CHANGELOG + +✅ **Update BEFORE release:** +- When milestone is complete +- Document all user-facing changes +- Include technical features if relevant + +❌ **Don't document:** +- Internal refactoring (unless architecturally significant) +- Development-only changes +- Code formatting/linting + +--- + +## 🏷️ Git Tag Format + +### Tag Structure + +``` +krow-withus--mobile/-v +``` + +### Examples + +**Staff Mobile (Worker):** +``` +krow-withus-worker-mobile/dev-v0.0.1-m3 +krow-withus-worker-mobile/stage-v0.0.1-m4 +krow-withus-worker-mobile/prod-v1.0.0 +``` + +**Client Mobile:** +``` +krow-withus-client-mobile/dev-v0.0.1-m3 +krow-withus-client-mobile/stage-v0.0.1-m4 +krow-withus-client-mobile/prod-v1.0.0 +``` + +### Tag Components + +| Component | Values | Example | +|-----------|--------|---------| +| Product | `worker`, `client` | `worker` | +| Type | `mobile` | `mobile` | +| Environment | `dev`, `stage`, `prod` | `dev` | +| Version | From pubspec.yaml | `v0.0.1-m3` | + +**Note:** Tags include the full version with milestone suffix (e.g., `v0.0.1-m4`, not just `v0.0.1`) + +--- + +## 🚀 Release Workflows + +### Release Types + +We have **2 GitHub Actions workflows** for releases: + +1. **Product Release** (`.github/workflows/product-release.yml`) - Standard releases +2. **Hotfix Branch Creation** (`.github/workflows/hotfix-branch-creation.yml`) - Emergency fixes + +Both workflows use **manual triggers only** (`workflow_dispatch`) - no automatic releases. + +--- + +## 📦 Standard Release Process + +### Step 1: Prepare Release + +1. **Ensure milestone is complete** + - All features implemented + - All tests passing + - Code reviews completed + +2. **Update CHANGELOG** + ```bash + # Edit the appropriate CHANGELOG file + vi apps/mobile/apps/staff/CHANGELOG.md + # OR + vi apps/mobile/apps/client/CHANGELOG.md + ``` + +3. **Update version in pubspec.yaml** + ```yaml + # apps/mobile/apps/staff/pubspec.yaml + version: 0.0.1-m4+1 + ``` + +4. **Commit changes** + ```bash + git add apps/mobile/apps/staff/CHANGELOG.md apps/mobile/apps/staff/pubspec.yaml + git commit -m "docs(mobile): prepare staff app v0.0.1-m4 release" + git push origin dev + ``` + +### Step 2: Trigger Release Workflow + +1. **Navigate to GitHub Actions** + - Go to: https://github.com/Oloodi/krow-workforce/actions + - Select **"📦 Product Release"** workflow + +2. **Click "Run workflow"** + +3. **Select parameters:** + - **Branch**: `dev` (or release branch) + - **Product**: `worker-mobile-app` or `client-mobile-app` + - **Environment**: `dev`, `stage`, or `prod` + - **Pre-release**: Check if this is not a production release + +4. **Click "Run workflow"** + +### Step 3: Monitor Workflow + +The workflow performs these steps automatically: + +1. ✅ **Validate & Create Release** (Job 1) + - Extract version from pubspec.yaml + - Validate version format + - Generate tag name + - Create Git tag + - Extract release notes from CHANGELOG + - Create GitHub Release with formatted notes + +2. 🔨 **Build Mobile Artifacts** (Job 2) + - Setup Node.js 20 + - Install Firebase CLI + - Generate Data Connect SDK + - Setup Java 17 + - Setup Flutter 3.38.x + - Bootstrap with Melos + - Decode keystore from secrets + - Build signed APK + - Verify APK signature + - Upload APK to GitHub Release + +### Step 4: Verify Release + +1. **Check GitHub Releases page** + - URL: https://github.com/Oloodi/krow-workforce/releases + - Verify release was created with correct tag + - Verify release notes display correctly + - Verify APK is attached (if applicable) + +2. **Test the release** + - Download APK (dev releases) + - Install on test device + - Verify app launches and core features work + +--- + +## 🔥 Hotfix Process + +### When to Use Hotfix + +✅ **Use hotfix for:** +- Critical bug in production affecting users +- Data loss or security vulnerability +- Service unavailable or major feature broken +- Customer-blocking issue + +❌ **Don't use hotfix for:** +- Minor bugs (can wait for next release) +- Feature requests +- UI/UX improvements +- Styling issues + +### Hotfix Workflow + +1. **Navigate to GitHub Actions** + - Go to: https://github.com/Oloodi/krow-workforce/actions + - Select **"🚨 Product Hotfix - Create Branch"** workflow + +2. **Click "Run workflow"** + +3. **Fill in parameters:** + - **Product**: `worker-mobile-app` or `client-mobile-app` + - **Current Production Version**: e.g., `1.0.0` (without 'v' prefix) + - **Issue Description**: Brief description of the bug (used in CHANGELOG and branch name) + +4. **The workflow automatically:** + - Creates hotfix branch: `hotfix/krow-withus-worker-mobile/prod-v1.0.1` + - Increments PATCH version: `1.0.0` → `1.0.1` + - Updates `pubspec.yaml` with new version + - Updates CHANGELOG.md with hotfix entry + - Creates Pull Request with hotfix instructions + +5. **Fix the bug:** + ```bash + # Checkout the hotfix branch + git fetch origin + git checkout hotfix/krow-withus-worker-mobile/prod-v1.0.1 + + # Make your fix + # ... edit files ... + + # Test thoroughly + flutter test + + # Commit your fix + git add . + git commit -m "fix(mobile): resolve critical production bug" + git push origin hotfix/krow-withus-worker-mobile/prod-v1.0.1 + ``` + +6. **Merge and Release:** + - Review and merge the Pull Request to `main` (or production branch) + - Trigger **Product Release** workflow with `prod` environment + - Workflow will create tag `krow-withus-worker-mobile/prod-v1.0.1` + - Deploy hotfix to production + +7. **Backport to dev:** + ```bash + git checkout dev + git merge hotfix/krow-withus-worker-mobile/prod-v1.0.1 + git push origin dev + ``` + +--- + +## 🔐 APK Signing Setup + +### Overview + +All Android builds require signing with keystores. We use **24 GitHub Secrets** (12 per app × 2 apps): + +- 6 keystores (2 apps × 3 environments) +- 4 secrets per keystore (base64, password, alias, key password) + +### Keystore Files + +**Worker Mobile (Staff App):** +- `krow_with_us_staff_dev.jks` - ✅ Committed to repo +- `krow_staff_staging.jks` - ⚠️ Store in GitHub Secrets only +- `krow_staff_prod.jks` - ⚠️ Store in GitHub Secrets only + +**Client Mobile:** +- `krow_with_us_client_dev.jks` - ✅ Committed to repo +- `krow_client_staging.jks` - ⚠️ Store in GitHub Secrets only +- `krow_client_prod.jks` - ⚠️ Store in GitHub Secrets only + +### Required GitHub Secrets + +#### Worker Mobile - 12 Secrets + +**Dev Environment:** +- `WORKER_KEYSTORE_DEV_BASE64` +- `WORKER_KEYSTORE_PASSWORD_DEV` +- `WORKER_KEY_ALIAS_DEV` +- `WORKER_KEY_PASSWORD_DEV` + +**Staging Environment:** +- `WORKER_KEYSTORE_STAGING_BASE64` +- `WORKER_KEYSTORE_PASSWORD_STAGING` +- `WORKER_KEY_ALIAS_STAGING` +- `WORKER_KEY_PASSWORD_STAGING` + +**Production Environment:** +- `WORKER_KEYSTORE_PROD_BASE64` +- `WORKER_KEYSTORE_PASSWORD_PROD` +- `WORKER_KEY_ALIAS_PROD` +- `WORKER_KEY_PASSWORD_PROD` + +#### Client Mobile - 12 Secrets + +**Dev Environment:** +- `CLIENT_KEYSTORE_DEV_BASE64` +- `CLIENT_KEYSTORE_PASSWORD_DEV` +- `CLIENT_KEY_ALIAS_DEV` +- `CLIENT_KEY_PASSWORD_DEV` + +**Staging Environment:** +- `CLIENT_KEYSTORE_STAGING_BASE64` +- `CLIENT_KEYSTORE_PASSWORD_STAGING` +- `CLIENT_KEY_ALIAS_STAGING` +- `CLIENT_KEY_PASSWORD_STAGING` + +**Production Environment:** +- `CLIENT_KEYSTORE_PROD_BASE64` +- `CLIENT_KEYSTORE_PASSWORD_PROD` +- `CLIENT_KEY_ALIAS_PROD` +- `CLIENT_KEY_PASSWORD_PROD` + +### Setup Using Helper Script + +We provide an interactive script to configure all secrets: + +```bash +.github/scripts/setup-mobile-github-secrets.sh +``` + +This script will: +1. Prompt for keystore file paths +2. Convert keystores to base64 +3. Prompt for passwords and aliases +4. Display GitHub CLI commands to set secrets +5. Optionally execute the commands + +### Manual Setup + +If you prefer manual setup: + +```bash +# 1. Convert keystore to base64 +base64 -i /path/to/keystore.jks | pbcopy + +# 2. Add to GitHub Secrets via web UI +# Go to: Repository → Settings → Secrets and variables → Actions +# Click "New repository secret" +# Name: WORKER_KEYSTORE_PROD_BASE64 +# Value: Paste the base64 string + +# 3. Repeat for all 24 secrets +``` + +Or use GitHub CLI: + +```bash +# Set a secret using gh CLI +gh secret set WORKER_KEYSTORE_PROD_BASE64 < /path/to/keystore_base64.txt + +# Set multiple secrets +gh secret set WORKER_KEYSTORE_PASSWORD_PROD -b "your_password" +gh secret set WORKER_KEY_ALIAS_PROD -b "your_alias" +gh secret set WORKER_KEY_PASSWORD_PROD -b "your_key_password" +``` + +### Verifying APK Signature + +After build, the workflow automatically verifies the APK signature using: + +```bash +.github/scripts/verify-apk-signature.sh +``` + +--- + +## 📅 Release Cadence + +### Development Releases (dev) + +- **Frequency**: As needed (daily/weekly) +- **Purpose**: Test features, integration testing +- **Stability**: Unstable, may have bugs +- **Distribution**: Internal testing only +- **APK**: Signed with dev keystore +- **Tag example**: `krow-withus-worker-mobile/dev-v0.0.1-m3` + +### Staging Releases (stage) + +- **Frequency**: Bi-weekly (end of sprints) +- **Purpose**: QA testing, client demos +- **Stability**: Stable, feature-complete +- **Distribution**: QA team, stakeholders +- **APK**: Signed with staging keystore +- **Tag example**: `krow-withus-worker-mobile/stage-v0.0.1-m4` + +### Production Releases (prod) + +- **Frequency**: Monthly or milestone-based +- **Purpose**: Public release to app stores +- **Stability**: Production-grade, thoroughly tested +- **Distribution**: Public (App Store, Play Store) +- **APK**: Signed with production keystore +- **Tag example**: `krow-withus-worker-mobile/prod-v1.0.0` + +--- + +## 🛠️ Helper Scripts Reference + +All scripts are located in `.github/scripts/` and are used by workflows: + +### 1. extract-version.sh + +**Purpose**: Extract version from pubspec.yaml +**Usage**: +```bash +.github/scripts/extract-version.sh +``` +**Example**: +```bash +VERSION=$(.github/scripts/extract-version.sh apps/mobile/apps/staff/pubspec.yaml) +echo $VERSION # Output: 0.0.1-m4 +``` + +### 2. generate-tag-name.sh + +**Purpose**: Generate consistent Git tag names +**Usage**: +```bash +.github/scripts/generate-tag-name.sh +``` +**Example**: +```bash +TAG=$(.github/scripts/generate-tag-name.sh worker dev 0.0.1-m4) +echo $TAG # Output: krow-withus-worker-mobile/dev-v0.0.1-m4 +``` + +### 3. extract-release-notes.sh + +**Purpose**: Extract CHANGELOG section for a specific version +**Usage**: +```bash +.github/scripts/extract-release-notes.sh +``` +**Example**: +```bash +NOTES=$(.github/scripts/extract-release-notes.sh worker dev 0.0.1-m4 krow-withus-worker-mobile/dev-v0.0.1-m4) +``` + +**Output format**: +``` +**Environment:** DEV +**Tag:** krow-withus-worker-mobile/dev-v0.0.1-m4 + +## What is new in this release + +[CHANGELOG content for v0.0.1-m4] +``` + +### 4. create-release-summary.sh + +**Purpose**: Generate GitHub Step Summary with emojis +**Usage**: +```bash +.github/scripts/create-release-summary.sh +``` +**Creates**: Formatted summary in GitHub Actions UI + +### 5. setup-apk-signing.sh + +**Purpose**: Setup APK signing environment variables +**Usage** (in workflow): +```bash +.github/scripts/setup-apk-signing.sh +``` +**What it does**: +- Decodes base64 keystore to file +- Sets `CM_KEYSTORE_PATH_` environment variable +- Sets keystore password, alias, and key password + +### 6. verify-apk-signature.sh + +**Purpose**: Verify APK is properly signed +**Usage**: +```bash +.github/scripts/verify-apk-signature.sh +``` +**Example**: +```bash +.github/scripts/verify-apk-signature.sh build/app/outputs/apk/release/app-release.apk androidreleasekey +``` + +### 7. attach-apk-to-release.sh + +**Purpose**: Upload APK to existing GitHub Release +**Usage**: +```bash +.github/scripts/attach-apk-to-release.sh +``` +**Example**: +```bash +.github/scripts/attach-apk-to-release.sh krow-withus-worker-mobile/dev-v0.0.1-m4 build/app/outputs/apk/release/app-release.apk worker +``` + +### 8. setup-mobile-github-secrets.sh + +**Purpose**: Interactive helper to configure all GitHub Secrets +**Usage**: +```bash +.github/scripts/setup-mobile-github-secrets.sh +``` +**Interactive prompts for**: +- Keystore file paths +- Passwords and aliases +- Generates GitHub CLI commands +- Optionally executes commands + +--- + +## 📋 Pre-Release Checklist + +Before triggering a release, ensure: + +### Code Quality +- [ ] All automated tests pass +- [ ] No critical linting errors +- [ ] Code review completed (for stage/prod) +- [ ] Security audit passed (for prod) + +### Documentation +- [ ] CHANGELOG.md updated with all changes +- [ ] Version in pubspec.yaml matches CHANGELOG +- [ ] Breaking changes documented +- [ ] Migration guide created (if needed) + +### Testing +- [ ] Feature testing completed +- [ ] Regression testing passed +- [ ] Performance testing acceptable +- [ ] Device compatibility verified + +### Configuration +- [ ] Environment variables configured +- [ ] API endpoints correct for environment +- [ ] Feature flags set appropriately +- [ ] Analytics tracking verified + +### GitHub Secrets (First-time setup) +- [ ] All 24 secrets configured +- [ ] Keystore passwords verified +- [ ] Test build succeeded with signing + +--- + +## 🐛 Troubleshooting + +### Workflow Fails: "Version not found in pubspec.yaml" + +**Cause**: Invalid version format or missing version +**Solution**: +```yaml +# Ensure version line in pubspec.yaml looks like: +version: 0.0.1-m4+1 +# Not: +version: 0.0.1 # Missing build number +version: "0.0.1-m4+1" # Don't quote the version +``` + +### Workflow Fails: "Secret not found" + +**Cause**: Missing GitHub Secret +**Solution**: +1. Check secret name matches exactly (case-sensitive) +2. Run `.github/scripts/setup-mobile-github-secrets.sh` +3. Verify secrets at: Repository → Settings → Secrets and variables → Actions + +### APK Signing Fails + +**Cause**: Invalid keystore or wrong password +**Solution**: +1. Verify keystore base64 encoding: `base64 -i keystore.jks | base64 -d > test.jks` +2. Test password locally: `keytool -list -keystore test.jks` +3. Verify alias: `keytool -list -v -keystore test.jks | grep "Alias name"` + +### CHANGELOG Not Extracted + +**Cause**: Version format doesn't match in CHANGELOG +**Solution**: +```markdown +# CHANGELOG.md must have this EXACT format: +## [v0.0.1-m4] - Milestone 4 - 2026-03-05 +# OR +## [0.0.1-m4] - Milestone 4 - 2026-03-05 + +# The script tries both [vX.Y.Z] and [X.Y.Z] formats +``` + +### Tag Already Exists + +**Cause**: Trying to create a duplicate tag +**Solution**: +```bash +# Delete the existing tag (CAREFUL!) +git tag -d krow-withus-worker-mobile/dev-v0.0.1-m4 +git push origin :refs/tags/krow-withus-worker-mobile/dev-v0.0.1-m4 + +# Then re-run the workflow +``` + +--- + +## 📚 Additional Resources + +### Related Documentation + +- [Agent Development Rules](../MOBILE/00-agent-development-rules.md) +- [Architecture Principles](../MOBILE/01-architecture-principles.md) +- [Mobile CI Workflow](../../.github/workflows/mobile-ci.yml) + +### GitHub Actions Workflows + +- **Product Release**: `.github/workflows/product-release.yml` +- **Hotfix Branch Creation**: `.github/workflows/hotfix-branch-creation.yml` +- **Mobile CI**: `.github/workflows/mobile-ci.yml` + +### Useful Commands + +```bash +# View current version +grep "^version:" apps/mobile/apps/staff/pubspec.yaml + +# List all mobile tags +git tag -l "krow-withus-*-mobile/*" + +# View latest releases +gh release list --limit 10 + +# Download APK from release +gh release download krow-withus-worker-mobile/dev-v0.0.1-m4 --pattern "*.apk" +``` + +--- + +## 🔄 Version History + +| Version | Date | Changes | +|---------|------|---------| +| 2.0 | 2026-03-06 | Consolidated all release docs into single file | +| 1.0 | 2026-03-05 | Initial separate documentation files | + +--- + +**Questions or Issues?** +Contact the DevOps team or create an issue in the repository. diff --git a/internal/launchpad/assets/data/links.json b/internal/launchpad/assets/data/links.json index 08bc2bea..b10af39b 100644 --- a/internal/launchpad/assets/data/links.json +++ b/internal/launchpad/assets/data/links.json @@ -1,4 +1,21 @@ [ + { + "title": "Demonstrations", + "iconColorClass": "bg-cyan-100", + "iconPath": "assets/images/icon-video.svg", + "links": [ + { + "title": "KROW Platform - M4 Demonstration", + "url": "https://www.youtube.com/embed/hD-Ngt5xfSc", + "badge": "Demo", + "badgeColorClass": "bg-cyan-500", + "containerClass": "bg-gradient-to-r from-cyan-50 to-blue-100 hover:from-cyan-100 hover:to-blue-200", + "iconClass": "w-2 h-2 bg-cyan-500 rounded-full", + "textHoverClass": "group-hover:text-cyan-700", + "isVideo": true + } + ] + }, { "title": "Applications", "iconColorClass": "bg-primary-100", diff --git a/internal/launchpad/assets/images/icon-video.svg b/internal/launchpad/assets/images/icon-video.svg new file mode 100644 index 00000000..3f7d6840 --- /dev/null +++ b/internal/launchpad/assets/images/icon-video.svg @@ -0,0 +1,3 @@ + + + diff --git a/internal/launchpad/assets/js/links-loader.js b/internal/launchpad/assets/js/links-loader.js index 47f20edf..8a5eb6cd 100644 --- a/internal/launchpad/assets/js/links-loader.js +++ b/internal/launchpad/assets/js/links-loader.js @@ -48,8 +48,9 @@ async function loadLinks() {
${group.links.map(link => { const isPrototype = link.url.startsWith('/prototypes/'); - const hrefAttr = isPrototype ? 'href="#"' : `href="${link.url}" target="_blank"`; - const onclickAttr = isPrototype ? `onclick="event.preventDefault(); showView('iframe', this, '${link.url}', '${link.title}')"` : ''; + const isVideo = link.isVideo === true; + const hrefAttr = (isPrototype || isVideo) ? 'href="#"' : `href="${link.url}" target="_blank"`; + const onclickAttr = (isPrototype || isVideo) ? `onclick="event.preventDefault(); showView('iframe', this, '${link.url}', '${link.title}')"` : ''; return ` Bootstrapping mobile workspace (Melos)..." @@ -40,35 +43,35 @@ mobile-hot-restart: # --- Client App --- mobile-client-dev-android: dataconnect-generate-sdk - @echo "--> Running client app on Android (device: $(DEVICE))..." - @cd $(MOBILE_DIR) && melos run start:client -- -d $(DEVICE) --dart-define-from-file=../../config.dev.json + @echo "--> Running client app on Android (device: $(DEVICE), env: $(ENV))..." + @cd $(MOBILE_DIR) && melos run start:client -- -d $(DEVICE) --flavor $(ENV) --dart-define-from-file=../../config.$(ENV).json mobile-client-build: dataconnect-generate-sdk @if [ -z "$(PLATFORM)" ]; then \ echo "ERROR: PLATFORM is required (e.g. make mobile-client-build PLATFORM=apk)"; exit 1; \ fi $(eval MODE ?= release) - @echo "--> Building client app for $(PLATFORM) in $(MODE) mode..." + @echo "--> Building client app for $(PLATFORM) in $(MODE) mode (env: $(ENV))..." @cd $(MOBILE_DIR) && \ - melos exec --scope="core_localization" -- "dart run slang" && \ - melos exec --scope="core_localization" -- "dart run build_runner build --delete-conflicting-outputs" && \ - melos exec --scope="krowwithus_client" -- "flutter build $(PLATFORM) --$(MODE) --dart-define-from-file=../../config.dev.json" + melos run gen:l10n && \ + melos run gen:build && \ + melos exec --scope="krowwithus_client" -- flutter build $(PLATFORM) --$(MODE) --flavor $(ENV) --dart-define-from-file=../../config.$(ENV).json # --- Staff App --- mobile-staff-dev-android: dataconnect-generate-sdk - @echo "--> Running staff app on Android (device: $(DEVICE))..." - @cd $(MOBILE_DIR) && melos run start:staff -- -d $(DEVICE) --dart-define-from-file=../../config.dev.json + @echo "--> Running staff app on Android (device: $(DEVICE), env: $(ENV))..." + @cd $(MOBILE_DIR) && melos run start:staff -- -d $(DEVICE) --flavor $(ENV) --dart-define-from-file=../../config.$(ENV).json mobile-staff-build: dataconnect-generate-sdk @if [ -z "$(PLATFORM)" ]; then \ echo "ERROR: PLATFORM is required (e.g. make mobile-staff-build PLATFORM=apk)"; exit 1; \ fi $(eval MODE ?= release) - @echo "--> Building staff app for $(PLATFORM) in $(MODE) mode..." + @echo "--> Building staff app for $(PLATFORM) in $(MODE) mode (env: $(ENV))..." @cd $(MOBILE_DIR) && \ - melos exec --scope="core_localization" -- "dart run slang" && \ - melos exec --scope="core_localization" -- "dart run build_runner build --delete-conflicting-outputs" && \ - melos exec --scope="krowwithus_staff" -- "flutter build $(PLATFORM) --$(MODE) --dart-define-from-file=../../config.dev.json" + melos run gen:l10n && \ + melos run gen:build && \ + melos exec --scope="krowwithus_staff" -- flutter build $(PLATFORM) --$(MODE) --flavor $(ENV) --dart-define-from-file=../../config.$(ENV).json # --- E2E (Maestro) --- # Set env before running: TEST_CLIENT_EMAIL, TEST_CLIENT_PASSWORD, TEST_CLIENT_COMPANY, TEST_STAFF_PHONE, TEST_STAFF_OTP, TEST_STAFF_SIGNUP_PHONE diff --git a/makefiles/tools.mk b/makefiles/tools.mk index 111433e2..823a491e 100644 --- a/makefiles/tools.mk +++ b/makefiles/tools.mk @@ -1,6 +1,6 @@ # --- Development Tools --- -.PHONY: install-git-hooks sync-prototypes install-melos clean-branches +.PHONY: install-git-hooks sync-prototypes install-melos clean-branches setup-mobile-ci-secrets install-melos: @if ! command -v melos >/dev/null 2>&1; then \ @@ -54,3 +54,8 @@ clean-branches: fi; \ done; \ echo "\n✅ Done! Deleted $$DELETED branch(es), skipped $$SKIPPED protected branch(es)." + +setup-mobile-ci-secrets: + @echo "--> Running GitHub Secrets setup helper for APK signing..." + @./.github/scripts/setup-mobile-github-secrets.sh + @echo "\n📚 For more information, see: docs/RELEASE/APK_SIGNING_SETUP.md"