diff --git a/.claude/agent-memory/architecture-reviewer/MEMORY.md b/.claude/agent-memory/architecture-reviewer/MEMORY.md index d23f742e..5db3c49a 100644 --- a/.claude/agent-memory/architecture-reviewer/MEMORY.md +++ b/.claude/agent-memory/architecture-reviewer/MEMORY.md @@ -18,12 +18,56 @@ - `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 +## Known Staff App Issues (full scan 2026-03-19) +- [recurring_violations.md](recurring_violations.md) - Detailed violation patterns + +### Critical +- ProfileCubit calls repository directly (no use cases, no interface) +- BenefitsOverviewCubit calls repository.getDashboard() directly (bypasses use case) +- StaffMainCubit missing BlocErrorHandler mixin +- firebase_auth imported directly in auth feature repos (2 files) + +### High (Widespread) +- 53 instances of `context.read<>()` without `ReadContext()` wrapper +- ~20 hardcoded Color(0x...) values in home/benefits widgets +- 5 custom TextStyle() in faqs_widget and tax_forms +- 8 copyWith(fontSize:) overrides on UiTypography +- ~40 hardcoded SizedBox spacing values +- Hardcoded nav labels in staff_nav_items_config.dart +- Zero test files across entire staff feature tree + ## Design System Tokens - Colors: `UiColors.*` - Typography: `UiTypography.*` - Spacing: `UiConstants.space*` (e.g., `space3`, `space4`, `space6`) - App bar: `UiAppBar` +## Known Client App Issues (full scan 2026-03-19) + +### Critical +- Reports feature: All 7 report BLoCs call ReportsRepository directly (no use cases) +- OneTimeOrderBloc, PermanentOrderBloc, RecurringOrderBloc call _queryRepository directly for loading vendors/hubs/roles +- OneTimeOrderBloc._onSubmitted has payload building business logic (should be in use case) +- ClientMainCubit missing BlocErrorHandler mixin +- firebase_auth imported directly in authentication and settings feature repos (2 packages) + +### High (Widespread) +- 17 hardcoded Color(0x...) across reports, coverage, billing, hubs +- 11 Material Colors.* usage (coverage, billing, reports) +- 66 standalone TextStyle() (almost all in reports feature) +- ~145 hardcoded EdgeInsets spacing values +- ~97 hardcoded SizedBox dimensions +- ~42 hardcoded BorderRadius.circular values +- 6 unsafe Modular.to.pop() calls (settings, hubs) +- BlocProvider(create:) used in no_show_report_page for Modular.get singleton +- Zero test files across entire client feature tree +- 2 hardcoded user-facing strings ("Export coming soon") +- 9 files with blanket ignore_for_file directives (reports feature) + +### Naming Convention Violations +- CoverageRepository, BillingRepository, ReportsRepository missing "Interface" suffix +- IViewOrdersRepository uses "I" prefix instead of "Interface" suffix + ## Review Patterns (grep-based checks) - `Color(0x` for hardcoded colors - `TextStyle(` for custom text styles diff --git a/.claude/agent-memory/mobile-feature-builder/MEMORY.md b/.claude/agent-memory/mobile-feature-builder/MEMORY.md new file mode 100644 index 00000000..f386504c --- /dev/null +++ b/.claude/agent-memory/mobile-feature-builder/MEMORY.md @@ -0,0 +1,3 @@ +# Mobile Feature Builder Memory Index + +- [firebase_auth_isolation.md](firebase_auth_isolation.md) - FirebaseAuthService in core abstracts all Firebase Auth operations; features must never import firebase_auth directly diff --git a/.claude/agent-memory/mobile-feature-builder/firebase_auth_isolation.md b/.claude/agent-memory/mobile-feature-builder/firebase_auth_isolation.md new file mode 100644 index 00000000..5f289095 --- /dev/null +++ b/.claude/agent-memory/mobile-feature-builder/firebase_auth_isolation.md @@ -0,0 +1,15 @@ +--- +name: Firebase Auth Isolation Pattern +description: FirebaseAuthService in core/lib/src/services/auth/ abstracts all Firebase Auth SDK operations so feature packages never import firebase_auth directly +type: project +--- + +`FirebaseAuthService` (interface) and `FirebaseAuthServiceImpl` live in `core/lib/src/services/auth/firebase_auth_service.dart`. + +Registered in `CoreModule` as `i.addLazySingleton(FirebaseAuthServiceImpl.new)`. + +Exported from `core.dart`. + +**Why:** Architecture rule requires firebase_auth only in core. Features inject `FirebaseAuthService` via DI. + +**How to apply:** Any new feature needing Firebase Auth operations (sign-in, sign-out, phone verification, get current user info) should depend on `FirebaseAuthService` from `krow_core`, not import `firebase_auth` directly. The service provides: `authStateChanges`, `currentUserPhoneNumber`, `currentUserUid`, `verifyPhoneNumber`, `signInWithPhoneCredential` (returns `PhoneSignInResult`), `signInWithEmailAndPassword`, `signOut`, `getIdToken`. diff --git a/.claude/agent-memory/ui-ux-design/MEMORY.md b/.claude/agent-memory/ui-ux-design/MEMORY.md new file mode 100644 index 00000000..38ee8187 --- /dev/null +++ b/.claude/agent-memory/ui-ux-design/MEMORY.md @@ -0,0 +1,7 @@ +# UI/UX Design Agent Memory + +## Index + +- [design-system-tokens.md](design-system-tokens.md) — Verified token values from actual source files +- [component-patterns.md](component-patterns.md) — Established component patterns in KROW staff app +- [design-gaps.md](design-gaps.md) — Known design system gaps and escalation items diff --git a/.claude/agent-memory/ui-ux-design/component-patterns.md b/.claude/agent-memory/ui-ux-design/component-patterns.md new file mode 100644 index 00000000..9ce4d7c2 --- /dev/null +++ b/.claude/agent-memory/ui-ux-design/component-patterns.md @@ -0,0 +1,87 @@ +--- +name: KROW Staff App Component Patterns +description: Established UI patterns, widget conventions, and design decisions confirmed in the KROW staff app codebase +type: project +--- + +## Card Pattern (standard surface) + +Cards use: +- `UiColors.cardViewBackground` (white) background +- `Border.all(color: UiColors.border)` outline +- `BorderRadius.circular(UiConstants.radiusBase)` = 12dp +- `EdgeInsets.all(UiConstants.space4)` = 16dp padding + +Do NOT use `UiColors.bgSecondary` as card background — that is for toggles/headers inside cards. + +## Section Toggle / Expand-Collapse Header + +Used for collapsible sections inside cards: +- Background: `UiColors.bgSecondary` +- Radius: `UiConstants.radiusMd` (6dp) +- Height: minimum 48dp (touch target) +- Label: `UiTypography.titleUppercase3m.textSecondary` for ALL-CAPS labels +- Trailing: `UiIcons.chevronDown` animated 180° via `AnimatedRotation`, 200ms +- Ripple: `InkWell` with `borderRadius: UiConstants.radiusMd` and splash `UiColors.primary.withValues(alpha: 0.06)` + +## Shimmer Loading Pattern + +Use `UiShimmer` wrapper + `UiShimmerLine` / `UiShimmerBox` / `UiShimmerCircle` primitives. +- Base color: `UiColors.muted` +- Highlight: `UiColors.background` +- For list content: 3 shimmer rows by default +- Do NOT use fixed height containers for shimmer — let content flow + +## Status Badge (read-only, non-interactive) + +Custom `Container` with pill shape: +- `borderRadius: UiConstants.radiusFull` +- `padding: EdgeInsets.symmetric(horizontal: space2, vertical: 2)` +- Label style: `UiTypography.footnote2b` +- Do NOT use the interactive `UiChip` widget for read-only display + +Status color mapping: +- ACTIVE: bg=`tagActive`, fg=`textSuccess` +- PENDING: bg=`tagPending`, fg=`textWarning` +- INACTIVE/ENDED: bg=`tagFreeze`, fg=`textSecondary` +- ERROR: bg=`tagError`, fg=`textError` + +## Inline Error Banner (inside card) + +NOT a full-page error — a compact container inside the widget: +- bg: `UiColors.tagError` +- radius: `UiConstants.radiusMd` +- Icon: `UiIcons.error` at `iconMd` (20dp), color: `UiColors.destructive` +- Title: `body2m.textError` +- Retry link: `body3r.primary` with `TextDecoration.underline` + +## Inline Empty State (inside card) + +NOT `UiEmptyState` widget (that is full-page). Use compact inline version: +- `Icon(UiIcons.clock, size: iconXl=32, color: UiColors.iconDisabled)` +- `body2r.textSecondary` label +- `EdgeInsets.symmetric(vertical: space6)` padding + +## AnimatedSize for Expand/Collapse + +```dart +AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + child: isExpanded ? content : const SizedBox.shrink(), +) +``` + +## Benefits Feature Structure + +Legacy benefits: `apps/mobile/legacy/legacy-staff-app/lib/features/profile/benefits/` +V2 domain entity: `apps/mobile/packages/domain/lib/src/entities/benefits/benefit.dart` +V2 history entity: needs creation at `packages/domain/lib/src/entities/benefits/benefit_history.dart` + +Benefit history is lazy-loaded per card (not with the initial overview fetch). +History state is cached in BLoC as `Map>>` keyed by benefitId. + +## Screen Page Pattern (overview pages) + +Uses `CustomScrollView` with `SliverList` for header + `SliverPadding` wrapping `SliverList.separated` for content. +Bottom padding on content sliver: `EdgeInsets.fromLTRB(16, 16, 16, 120)` to clear bottom nav bar. diff --git a/.claude/agent-memory/ui-ux-design/design-gaps.md b/.claude/agent-memory/ui-ux-design/design-gaps.md new file mode 100644 index 00000000..ba7f44f5 --- /dev/null +++ b/.claude/agent-memory/ui-ux-design/design-gaps.md @@ -0,0 +1,22 @@ +--- +name: KROW Design System Gaps and Escalations +description: Known missing tokens, open design questions, and items requiring escalation to PM or design system owner +type: project +--- + +## Open Escalations (as of 2026-03-18) + +### 1. No Dark Theme Token Definitions +**Severity:** High +**Detail:** `ui_colors.dart` defines a single light `ColorScheme`. Tag colors (`tagActive`, `tagPending`, `tagFreeze`, `tagError`) have no dark mode equivalents. No dark theme has been configured in `UiTheme`. +**Action:** Escalate to design system owner before any dark mode work. Until resolved, do not attempt dark mode overrides in feature widgets. + +### 2. V2 History API — trackedHours Sign Convention +**Severity:** Medium +**Detail:** `GET /staff/profile/benefits/history` returns `trackedHours` as a positive integer. There is no `transactionType` field to distinguish accruals from deductions (used hours). Design assumes accrual-only for now with `+` prefix in `UiColors.textSuccess`. +**Action:** Escalate to PM/backend. Recommend adding `transactionType: "ACCRUAL" | "USAGE"` or signed integer to distinguish visually. + +### 3. Missing Localization Keys for Benefits History +**Severity:** Low (implementation blocker, not design blocker) +**Detail:** New keys under `benefits.history.*` need to be added to both `en.i18n.json` and `es.i18n.json` in `packages/core_localization/lib/src/l10n/`. Must be coordinated with Mobile Feature Agent who runs `dart run slang`. +**Action:** Hand off key list to Mobile Feature Agent. diff --git a/.claude/agent-memory/ui-ux-design/design-system-tokens.md b/.claude/agent-memory/ui-ux-design/design-system-tokens.md new file mode 100644 index 00000000..49bed2b7 --- /dev/null +++ b/.claude/agent-memory/ui-ux-design/design-system-tokens.md @@ -0,0 +1,102 @@ +--- +name: KROW Design System Token Reference +description: Verified token values from actual source files in apps/mobile/packages/design_system/lib/src/ +type: reference +--- + +## Source Files (verified 2026-03-18) + +- `ui_colors.dart` — all color tokens +- `ui_typography.dart` — all text styles (primary font: Instrument Sans, secondary: Space Grotesk) +- `ui_constants.dart` — spacing, radius, icon sizes +- `ui_icons.dart` — icon aliases over LucideIcons (primary) + FontAwesomeIcons (secondary) + +## Key Color Tokens (hex values confirmed) + +| Token | Hex | Use | +|-------|-----|-----| +| `UiColors.background` | `#FAFBFC` | Page background | +| `UiColors.cardViewBackground` | `#FFFFFF` | Card surface | +| `UiColors.bgSecondary` | `#F1F3F5` | Toggle/section headers | +| `UiColors.bgThird` | `#EDF0F2` | — | +| `UiColors.primary` | `#0A39DF` | Brand blue | +| `UiColors.textPrimary` | `#121826` | Main text | +| `UiColors.textSecondary` | `#6A7382` | Secondary/muted text | +| `UiColors.textInactive` | `#9CA3AF` | Disabled/placeholder | +| `UiColors.textSuccess` | `#0A8159` | Green text (darker than success icon) | +| `UiColors.textError` | `#F04444` | Red text | +| `UiColors.textWarning` | `#D97706` | Amber text | +| `UiColors.success` | `#10B981` | Green brand color | +| `UiColors.destructive` | `#F04444` | Red brand color | +| `UiColors.border` | `#D1D5DB` | Default border | +| `UiColors.separatorSecondary` | `#F1F5F9` | Light dividers | +| `UiColors.tagActive` | `#DCFCE7` | Active status badge bg | +| `UiColors.tagPending` | `#FEF3C7` | Pending badge bg | +| `UiColors.tagError` | `#FEE2E2` | Error banner bg | +| `UiColors.tagFreeze` | `#F3F4F6` | Ended/frozen badge bg | +| `UiColors.tagInProgress` | `#DBEAFE` | In-progress badge bg | +| `UiColors.iconDisabled` | `#D1D5DB` | Disabled icon color | +| `UiColors.muted` | `#F1F3F5` | Shimmer base color | + +## Key Spacing Constants + +| Token | Value | +|-------|-------| +| `space1` | 4dp | +| `space2` | 8dp | +| `space3` | 12dp | +| `space4` | 16dp | +| `space5` | 20dp | +| `space6` | 24dp | +| `space8` | 32dp | +| `space10` | 40dp | +| `space12` | 48dp | + +## Key Radius Constants + +| Token | Value | +|-------|-------| +| `radiusSm` | 4dp | +| `radiusMd` (radiusMdValue) | 6dp | +| `radiusBase` | 12dp | +| `radiusLg` | 12dp (BorderRadius.circular(12)) | +| `radiusXl` | 16dp | +| `radius2xl` | 24dp | +| `radiusFull` | 999dp | + +NOTE: `radiusBase` is a `double` (12.0), `radiusLg` is a `BorderRadius`. Use `BorderRadius.circular(UiConstants.radiusBase)` when a double is needed. + +## Icon Sizes + +| Token | Value | +|-------|-------| +| `iconXs` | 12dp | +| `iconSm` | 16dp | +| `iconMd` | 20dp | +| `iconLg` | 24dp | +| `iconXl` | 32dp | + +## Key Typography Styles (Instrument Sans) + +| Token | Size | Weight | Notes | +|-------|------|--------|-------| +| `display1b` | 26px | 600 | letterSpacing: -1 | +| `title1b` | 18px | 600 | height: 1.5 | +| `title1m` | 18px | 500 | height: 1.5 | +| `title2b` | 16px | 600 | height: 1.1 | +| `body1m` | 16px | 600 | letterSpacing: -0.025 | +| `body1r` | 16px | 400 | letterSpacing: -0.05 | +| `body2b` | 14px | 700 | height: 1.5 | +| `body2m` | 14px | 500 | height: 1.5 | +| `body2r` | 14px | 400 | letterSpacing: 0.1 | +| `body3r` | 12px | 400 | height: 1.5 | +| `body3m` | 12px | 500 | letterSpacing: -0.1 | +| `footnote1r` | 12px | 400 | letterSpacing: 0.05 | +| `footnote1m` | 12px | 500 | — | +| `footnote2b` | 10px | 700 | — | +| `footnote2r` | 10px | 400 | — | +| `titleUppercase3m` | 12px | 500 | letterSpacing: 0.7 — use for ALL-CAPS section labels | + +## Typography Color Extension + +`UiTypography` styles have a `.textSecondary`, `.textSuccess`, `.textError`, `.textWarning`, `.primary`, `.white` extension defined in `TypographyColors`. Use these instead of `.copyWith(color: ...)` where possible for brevity. diff --git a/.claude/agents/bug-reporter.md b/.claude/agents/bug-reporter.md new file mode 100644 index 00000000..74d23b1c --- /dev/null +++ b/.claude/agents/bug-reporter.md @@ -0,0 +1,247 @@ +--- +name: bug-reporter +description: "Use this agent when you need to create a GitHub issue to report a bug, request a feature, or document a technical task. This includes when a bug is discovered during development, when a TODO or known issue is identified in the codebase, when a feature request needs to be formally tracked, or when technical debt needs to be documented.\\n\\nExamples:\\n\\n- User: \"I found a bug where the order total calculates incorrectly when discounts are applied\"\\n Assistant: \"Let me use the bug-reporter agent to create a well-structured GitHub issue for this calculation bug.\"\\n (Use the Agent tool to launch the bug-reporter agent with the bug context)\\n\\n- User: \"We need to track that the session timeout doesn't redirect properly on the client app\"\\n Assistant: \"I'll use the bug-reporter agent to file this as a GitHub issue with the right labels and context.\"\\n (Use the Agent tool to launch the bug-reporter agent)\\n\\n- After discovering an issue during code review or development:\\n Assistant: \"I noticed a potential race condition in the BLoC disposal logic. Let me use the bug-reporter agent to create a tracked issue for this.\"\\n (Use the Agent tool to launch the bug-reporter agent proactively)\\n\\n- User: \"Create a feature request for adding push notification support to the staff app\"\\n Assistant: \"I'll use the bug-reporter agent to create a well-structured feature request issue on GitHub.\"\\n (Use the Agent tool to launch the bug-reporter agent)" +model: haiku +color: yellow +memory: project +--- + +You are an expert GitHub Issue Reporter specializing in creating clear, actionable, and well-structured issues for software projects. You have deep experience in bug triage, issue classification, and technical writing for development teams. + +You have access to the GitHub CLI (`gh`) and GitHub MCP tools. Use `gh` commands as your primary tool, falling back to GitHub MCP if needed. + +## Your Primary Mission + +Create well-structured GitHub issues with comprehensive context that enables any developer to understand, reproduce, and resolve the issue efficiently. + +## Before Creating an Issue + +1. **Determine the repository**: Run `gh repo view --json nameWithOwner -q .nameWithOwner` to confirm the current repo. +2. **Check existing labels**: Run `gh label list` to see available labels. Only use labels that exist in the repository. +3. **Check for duplicates**: Run `gh issue list --search ""` to avoid creating duplicate issues. +4. **Determine issue type**: Classify as one of: bug, feature request, technical debt, enhancement, chore, or documentation. + +## Issue Structure + +Every issue MUST contain these sections, formatted in Markdown: + +### For Bugs: +``` +## Context +[Background on the feature/area affected, why it matters, and how it was discovered] + +## Current State (Bug Behavior) +[What is currently happening — be specific with error messages, incorrect outputs, or unexpected behavior] + +## Expected Behavior +[What should happen instead] + +## Steps to Reproduce +[Numbered steps to reliably reproduce the issue, if known] + +## Suggested Approach +[Technical guidance on where the fix likely needs to happen — files, functions, architectural layers] + +## Additional Context +[Screenshots, logs, related issues, environment details, or any other relevant information] +``` + +### For Feature Requests: +``` +## Context +[Background on why this feature is needed, user pain points, or business requirements] + +## Current State +[How things work today without this feature, any workarounds in use] + +## What's Needed +[Clear description of the desired functionality and acceptance criteria] + +## Suggested Approach +[Technical approach, architecture considerations, affected components] + +## Additional Context +[Mockups, references, related features, or dependencies] +``` + +### For Technical Debt / Chores: +``` +## Context +[Background on the technical area and why this work matters] + +## Current State +[What the current implementation looks like and its problems] + +## What Needs to Change +[Specific improvements or refactoring required] + +## Suggested Approach +[Step-by-step technical plan, migration strategy if applicable] + +## Impact & Risk +[What areas are affected, potential risks, testing considerations] +``` + +## Label Selection + +Apply labels based on these criteria (only use labels that exist in the repo): + +- **Type labels**: `bug`, `enhancement`, `feature`, `chore`, `documentation`, `technical-debt` +- **Priority labels**: `priority: critical`, `priority: high`, `priority: medium`, `priority: low` +- **Area labels**: Match to the affected area (e.g., `mobile`, `web`, `backend`, `api`, `ui`, `infrastructure`) +- **Status labels**: `good first issue`, `help wanted` if applicable + +If unsure about a label's existence, check with `gh label list` first. Never fabricate labels. + +## Creating the Issue + +Use this command pattern: +```bash +gh issue create --title "" --body "" --label "," +``` + +**Title conventions:** +- Bugs: `[Bug] ` +- Features: `[Feature] ` +- Tech Debt: `[Tech Debt] ` +- Chore: `[Chore] ` + +## Quality Checklist (Self-Verify Before Submitting) + +- [ ] Title is clear and descriptive (someone can understand the issue from the title alone) +- [ ] All required sections are filled with specific, actionable content +- [ ] Labels are valid (verified against repo's label list) +- [ ] No duplicate issue exists +- [ ] Technical details reference specific files, functions, or components when possible +- [ ] The suggested approach is realistic and aligns with the project's architecture +- [ ] Markdown formatting is correct + +## Important Rules + +- Always confirm the issue details with the user before creating it, unless explicitly told to proceed +- If context is insufficient, ask clarifying questions before creating the issue +- Reference specific file paths, component names, and code patterns from the codebase when possible +- For this KROW project: reference the Clean Architecture layers, BLoC patterns, feature package paths, and V2 API conventions as appropriate +- After creating the issue, display the issue URL and a summary of what was created +- If `gh` auth fails, guide the user through `gh auth login` or fall back to GitHub MCP tools + +# Persistent Agent Memory + +You have a persistent, file-based memory system at `/Users/achinthaisuru/Documents/GitHub/krow-workforce/apps/mobile/packages/core_localization/.claude/agent-memory/bug-reporter/`. This directory already exists — write to it directly with the Write tool (do not run mkdir or check for its existence). + +You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you. + +If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry. + +## Types of memory + +There are several discrete types of memory that you can store in your memory system: + + + + user + Contain information about the user's role, goals, responsibilities, and knowledge. Great user memories help you tailor your future behavior to the user's preferences and perspective. Your goal in reading and writing these memories is to build up an understanding of who the user is and how you can be most helpful to them specifically. For example, you should collaborate with a senior software engineer differently than a student who is coding for the very first time. Keep in mind, that the aim here is to be helpful to the user. Avoid writing memories about the user that could be viewed as a negative judgement or that are not relevant to the work you're trying to accomplish together. + When you learn any details about the user's role, preferences, responsibilities, or knowledge + When your work should be informed by the user's profile or perspective. For example, if the user is asking you to explain a part of the code, you should answer that question in a way that is tailored to the specific details that they will find most valuable or that helps them build their mental model in relation to domain knowledge they already have. + + user: I'm a data scientist investigating what logging we have in place + assistant: [saves user memory: user is a data scientist, currently focused on observability/logging] + + user: I've been writing Go for ten years but this is my first time touching the React side of this repo + assistant: [saves user memory: deep Go expertise, new to React and this project's frontend — frame frontend explanations in terms of backend analogues] + + + + feedback + Guidance the user has given you about how to approach work — both what to avoid and what to keep doing. These are a very important type of memory to read and write as they allow you to remain coherent and responsive to the way you should approach work in the project. Record from failure AND success: if you only save corrections, you will avoid past mistakes but drift away from approaches the user has already validated, and may grow overly cautious. + Any time the user corrects your approach ("no not that", "don't", "stop doing X") OR confirms a non-obvious approach worked ("yes exactly", "perfect, keep doing that", accepting an unusual choice without pushback). Corrections are easy to notice; confirmations are quieter — watch for them. In both cases, save what is applicable to future conversations, especially if surprising or not obvious from the code. Include *why* so you can judge edge cases later. + Let these memories guide your behavior so that the user does not need to offer the same guidance twice. + Lead with the rule itself, then a **Why:** line (the reason the user gave — often a past incident or strong preference) and a **How to apply:** line (when/where this guidance kicks in). Knowing *why* lets you judge edge cases instead of blindly following the rule. + + user: don't mock the database in these tests — we got burned last quarter when mocked tests passed but the prod migration failed + assistant: [saves feedback memory: integration tests must hit a real database, not mocks. Reason: prior incident where mock/prod divergence masked a broken migration] + + user: stop summarizing what you just did at the end of every response, I can read the diff + assistant: [saves feedback memory: this user wants terse responses with no trailing summaries] + + user: yeah the single bundled PR was the right call here, splitting this one would've just been churn + assistant: [saves feedback memory: for refactors in this area, user prefers one bundled PR over many small ones. Confirmed after I chose this approach — a validated judgment call, not a correction] + + + + project + Information that you learn about ongoing work, goals, initiatives, bugs, or incidents within the project that is not otherwise derivable from the code or git history. Project memories help you understand the broader context and motivation behind the work the user is doing within this working directory. + When you learn who is doing what, why, or by when. These states change relatively quickly so try to keep your understanding of this up to date. Always convert relative dates in user messages to absolute dates when saving (e.g., "Thursday" → "2026-03-05"), so the memory remains interpretable after time passes. + Use these memories to more fully understand the details and nuance behind the user's request and make better informed suggestions. + Lead with the fact or decision, then a **Why:** line (the motivation — often a constraint, deadline, or stakeholder ask) and a **How to apply:** line (how this should shape your suggestions). Project memories decay fast, so the why helps future-you judge whether the memory is still load-bearing. + + user: we're freezing all non-critical merges after Thursday — mobile team is cutting a release branch + assistant: [saves project memory: merge freeze begins 2026-03-05 for mobile release cut. Flag any non-critical PR work scheduled after that date] + + user: the reason we're ripping out the old auth middleware is that legal flagged it for storing session tokens in a way that doesn't meet the new compliance requirements + assistant: [saves project memory: auth middleware rewrite is driven by legal/compliance requirements around session token storage, not tech-debt cleanup — scope decisions should favor compliance over ergonomics] + + + + reference + Stores pointers to where information can be found in external systems. These memories allow you to remember where to look to find up-to-date information outside of the project directory. + When you learn about resources in external systems and their purpose. For example, that bugs are tracked in a specific project in Linear or that feedback can be found in a specific Slack channel. + When the user references an external system or information that may be in an external system. + + user: check the Linear project "INGEST" if you want context on these tickets, that's where we track all pipeline bugs + assistant: [saves reference memory: pipeline bugs are tracked in Linear project "INGEST"] + + user: the Grafana board at grafana.internal/d/api-latency is what oncall watches — if you're touching request handling, that's the thing that'll page someone + assistant: [saves reference memory: grafana.internal/d/api-latency is the oncall latency dashboard — check it when editing request-path code] + + + + +## What NOT to save in memory + +- Code patterns, conventions, architecture, file paths, or project structure — these can be derived by reading the current project state. +- Git history, recent changes, or who-changed-what — `git log` / `git blame` are authoritative. +- Debugging solutions or fix recipes — the fix is in the code; the commit message has the context. +- Anything already documented in CLAUDE.md files. +- Ephemeral task details: in-progress work, temporary state, current conversation context. + +## How to save memories + +Saving a memory is a two-step process: + +**Step 1** — write the memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format: + +```markdown +--- +name: {{memory name}} +description: {{one-line description — used to decide relevance in future conversations, so be specific}} +type: {{user, feedback, project, reference}} +--- + +{{memory content — for feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines}} +``` + +**Step 2** — add a pointer to that file in `MEMORY.md`. `MEMORY.md` is an index, not a memory — it should contain only links to memory files with brief descriptions. It has no frontmatter. Never write memory content directly into `MEMORY.md`. + +- `MEMORY.md` is always loaded into your conversation context — lines after 200 will be truncated, so keep the index concise +- Keep the name, description, and type fields in memory files up-to-date with the content +- Organize memory semantically by topic, not chronologically +- Update or remove memories that turn out to be wrong or outdated +- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one. + +## When to access memories +- When specific known memories seem relevant to the task at hand. +- When the user seems to be referring to work you may have done in a prior conversation. +- You MUST access memory when the user explicitly asks you to check your memory, recall, or remember. +- Memory records what was true when it was written. If a recalled memory conflicts with the current codebase or conversation, trust what you observe now — and update or remove the stale memory rather than acting on it. + +## Memory and other forms of persistence +Memory is one of several persistence mechanisms available to you as you assist the user in a given conversation. The distinction is often that memory can be recalled in future conversations and should not be used for persisting information that is only useful within the scope of the current conversation. +- When to use or update a plan instead of memory: If you are about to start a non-trivial implementation task and would like to reach alignment with the user on your approach you should use a Plan rather than saving this information to memory. Similarly, if you already have a plan within the conversation and you have changed your approach persist that change by updating the plan rather than saving a memory. +- When to use or update tasks instead of memory: When you need to break your work in current conversation into discrete steps or keep track of your progress use tasks instead of saving to memory. Tasks are great for persisting information about the work that needs to be done in the current conversation, but memory should be reserved for information that will be useful 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 save new memories, they will appear here. 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 968d1a3f..810bbf85 100644 --- a/apps/mobile/apps/client/lib/src/widgets/session_listener.dart +++ b/apps/mobile/apps/client/lib/src/widgets/session_listener.dart @@ -1,5 +1,6 @@ import 'dart:async'; +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'; @@ -84,7 +85,7 @@ class _SessionListenerState extends State { if (!_isInitialState) { debugPrint('[SessionListener] Session error: ${state.errorMessage}'); _showSessionErrorDialog( - state.errorMessage ?? 'Session error occurred', + state.errorMessage ?? t.session.error_title, ); } else { _isInitialState = false; @@ -101,22 +102,21 @@ class _SessionListenerState extends State { /// Shows a dialog when the session expires. void _showSessionExpiredDialog() { + final Translations translations = t; showDialog( context: context, barrierDismissible: false, - builder: (BuildContext context) { + builder: (BuildContext dialogContext) { return AlertDialog( - title: const Text('Session Expired'), - content: const Text( - 'Your session has expired. Please log in again to continue.', - ), + title: Text(translations.session.expired_title), + content: Text(translations.session.expired_message), actions: [ TextButton( onPressed: () { - Modular.to.popSafe(); + Navigator.of(dialogContext).pop(); _proceedToLogin(); }, - child: const Text('Log In'), + child: Text(translations.session.log_in), ), ], ); @@ -126,27 +126,28 @@ class _SessionListenerState extends State { /// Shows a dialog when a session error occurs, with retry option. void _showSessionErrorDialog(String errorMessage) { + final Translations translations = t; showDialog( context: context, barrierDismissible: false, - builder: (BuildContext context) { + builder: (BuildContext dialogContext) { return AlertDialog( - title: const Text('Session Error'), + title: Text(translations.session.error_title), content: Text(errorMessage), actions: [ TextButton( onPressed: () { // User can retry by dismissing and continuing - Modular.to.popSafe(); + Navigator.of(dialogContext).pop(); }, - child: const Text('Continue'), + child: Text(translations.common.continue_text), ), TextButton( onPressed: () { - Modular.to.popSafe(); + Navigator.of(dialogContext).pop(); _proceedToLogin(); }, - child: const Text('Log Out'), + child: Text(translations.session.log_out), ), ], ); diff --git a/apps/mobile/apps/client/pubspec.yaml b/apps/mobile/apps/client/pubspec.yaml index 0949ea04..2854b683 100644 --- a/apps/mobile/apps/client/pubspec.yaml +++ b/apps/mobile/apps/client/pubspec.yaml @@ -33,8 +33,9 @@ dependencies: client_create_order: path: ../../packages/features/client/orders/create_order krow_core: - path: ../../packages/core - + path: ../../packages/core + krow_domain: + path: ../../packages/domain cupertino_icons: ^1.0.8 flutter_modular: ^6.3.2 flutter_bloc: ^8.1.3 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 a07aa31f..fe5bac48 100644 --- a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart +++ b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart @@ -1,5 +1,6 @@ import 'dart:async'; +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'; @@ -97,7 +98,7 @@ class _SessionListenerState extends State { if (!_isInitialState) { debugPrint('[SessionListener] Session error: ${state.errorMessage}'); _showSessionErrorDialog( - state.errorMessage ?? 'Session error occurred', + state.errorMessage ?? t.session.error_title, ); } else { _isInitialState = false; @@ -114,22 +115,21 @@ class _SessionListenerState extends State { /// Shows a dialog when the session expires. void _showSessionExpiredDialog() { + final Translations translations = t; showDialog( context: context, barrierDismissible: false, - builder: (BuildContext context) { + builder: (BuildContext dialogContext) { return AlertDialog( - title: const Text('Session Expired'), - content: const Text( - 'Your session has expired. Please log in again to continue.', - ), + title: Text(translations.session.expired_title), + content: Text(translations.session.expired_message), actions: [ TextButton( onPressed: () { - Modular.to.popSafe(); + Navigator.of(dialogContext).pop(); _proceedToLogin(); }, - child: const Text('Log In'), + child: Text(translations.session.log_in), ), ], ); @@ -139,27 +139,28 @@ class _SessionListenerState extends State { /// Shows a dialog when a session error occurs, with retry option. void _showSessionErrorDialog(String errorMessage) { + final Translations translations = t; showDialog( context: context, barrierDismissible: false, - builder: (BuildContext context) { + builder: (BuildContext dialogContext) { return AlertDialog( - title: const Text('Session Error'), + title: Text(translations.session.error_title), content: Text(errorMessage), actions: [ TextButton( onPressed: () { // User can retry by dismissing and continuing - Modular.to.popSafe(); + Navigator.of(dialogContext).pop(); }, - child: const Text('Continue'), + child: Text(translations.common.continue_text), ), TextButton( onPressed: () { - Modular.to.popSafe(); + Navigator.of(dialogContext).pop(); _proceedToLogin(); }, - child: const Text('Log Out'), + child: Text(translations.session.log_out), ), ], ); diff --git a/apps/mobile/apps/staff/pubspec.yaml b/apps/mobile/apps/staff/pubspec.yaml index de4181dc..69b8ed4e 100644 --- a/apps/mobile/apps/staff/pubspec.yaml +++ b/apps/mobile/apps/staff/pubspec.yaml @@ -28,6 +28,8 @@ dependencies: path: ../../packages/features/staff/staff_main krow_core: path: ../../packages/core + krow_domain: + path: ../../packages/domain cupertino_icons: ^1.0.8 flutter_modular: ^6.3.0 firebase_core: ^4.4.0 diff --git a/apps/mobile/packages/core/lib/core.dart b/apps/mobile/packages/core/lib/core.dart index f9143b60..57be3d54 100644 --- a/apps/mobile/packages/core/lib/core.dart +++ b/apps/mobile/packages/core/lib/core.dart @@ -42,6 +42,10 @@ export 'src/services/session/client_session_store.dart'; export 'src/services/session/staff_session_store.dart'; export 'src/services/session/v2_session_service.dart'; +// Auth +export 'src/services/auth/auth_token_provider.dart'; +export 'src/services/auth/firebase_auth_service.dart'; + // Device Services export 'src/services/device/camera/camera_service.dart'; export 'src/services/device/gallery/gallery_service.dart'; diff --git a/apps/mobile/packages/core/lib/src/core_module.dart b/apps/mobile/packages/core/lib/src/core_module.dart index 40145f27..7499a553 100644 --- a/apps/mobile/packages/core/lib/src/core_module.dart +++ b/apps/mobile/packages/core/lib/src/core_module.dart @@ -3,6 +3,10 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:image_picker/image_picker.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:krow_core/src/services/auth/auth_token_provider.dart'; +import 'package:krow_core/src/services/auth/firebase_auth_service.dart'; +import 'package:krow_core/src/services/auth/firebase_auth_token_provider.dart'; + import '../core.dart'; /// A module that provides core services and shared dependencies. @@ -57,7 +61,13 @@ class CoreModule extends Module { ), ); - // 6. Register Geofence Device Services + // 6. Auth Token Provider + i.addLazySingleton(FirebaseAuthTokenProvider.new); + + // 7. Firebase Auth Service (so features never import firebase_auth) + i.addLazySingleton(FirebaseAuthServiceImpl.new); + + // 8. Register Geofence Device Services i.addLazySingleton(() => const LocationService()); i.addLazySingleton(() => NotificationService()); i.addLazySingleton(() => StorageService()); diff --git a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart index 686ea53c..9a536a65 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart @@ -60,6 +60,20 @@ extension StaffNavigator on IModularNavigator { safePush(StaffPaths.benefits); } + /// Navigates to the full history page for a specific benefit. + void toBenefitHistory({ + required String benefitId, + required String benefitTitle, + }) { + safePush( + StaffPaths.benefitHistory, + arguments: { + 'benefitId': benefitId, + 'benefitTitle': benefitTitle, + }, + ); + } + void toStaffMain() { safePushNamedAndRemoveUntil('${StaffPaths.main}/home/', (_) => false); } diff --git a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart index 42b159d3..c3ebff23 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart @@ -75,6 +75,9 @@ class StaffPaths { /// Benefits overview page. static const String benefits = '/worker-main/home/benefits'; + /// Benefit history page for a specific benefit. + static const String benefitHistory = '/worker-main/home/benefits/history'; + /// Shifts tab - view and manage shifts. /// /// Browse available shifts, accepted shifts, and shift history. diff --git a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart index 714172bb..aeb0f45f 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart @@ -48,6 +48,26 @@ abstract final class ClientEndpoints { static const ApiEndpoint coverageCoreTeam = ApiEndpoint('/client/coverage/core-team'); + /// Coverage incidents. + static const ApiEndpoint coverageIncidents = + ApiEndpoint('/client/coverage/incidents'); + + /// Blocked staff. + static const ApiEndpoint coverageBlockedStaff = + ApiEndpoint('/client/coverage/blocked-staff'); + + /// Coverage swap requests. + static const ApiEndpoint coverageSwapRequests = + ApiEndpoint('/client/coverage/swap-requests'); + + /// Dispatch teams. + static const ApiEndpoint coverageDispatchTeams = + ApiEndpoint('/client/coverage/dispatch-teams'); + + /// Dispatch candidates. + static const ApiEndpoint coverageDispatchCandidates = + ApiEndpoint('/client/coverage/dispatch-candidates'); + /// Hubs list. static const ApiEndpoint hubs = ApiEndpoint('/client/hubs'); @@ -162,4 +182,28 @@ abstract final class ClientEndpoints { /// Cancel late worker assignment. static ApiEndpoint coverageCancelLateWorker(String assignmentId) => ApiEndpoint('/client/coverage/late-workers/$assignmentId/cancel'); + + /// Register or delete device push token (POST to register, DELETE to remove). + static const ApiEndpoint devicesPushTokens = + ApiEndpoint('/client/devices/push-tokens'); + + /// Create shift manager. + static const ApiEndpoint shiftManagerCreate = + ApiEndpoint('/client/shift-managers'); + + /// Resolve coverage swap request by ID. + static ApiEndpoint coverageSwapRequestResolve(String id) => + ApiEndpoint('/client/coverage/swap-requests/$id/resolve'); + + /// Cancel coverage swap request by ID. + static ApiEndpoint coverageSwapRequestCancel(String id) => + ApiEndpoint('/client/coverage/swap-requests/$id/cancel'); + + /// Create dispatch team membership. + static const ApiEndpoint coverageDispatchTeamMembershipsCreate = + ApiEndpoint('/client/coverage/dispatch-teams/memberships'); + + /// Delete dispatch team membership by ID. + static ApiEndpoint coverageDispatchTeamMembershipsDelete(String id) => + ApiEndpoint('/client/coverage/dispatch-teams/memberships/$id'); } diff --git a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/core_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/core_endpoints.dart index 8c18a244..7931ad99 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/core_endpoints.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/core_endpoints.dart @@ -2,39 +2,42 @@ import 'package:krow_domain/krow_domain.dart' show ApiEndpoint; /// Core infrastructure endpoints (upload, signed URLs, LLM, verifications, /// rapid orders). +/// +/// Paths are at the unified API root level (not under `/core/`). abstract final class CoreEndpoints { /// Upload a file. - static const ApiEndpoint uploadFile = - ApiEndpoint('/core/upload-file'); + static const ApiEndpoint uploadFile = ApiEndpoint('/upload-file'); /// Create a signed URL for a file. - static const ApiEndpoint createSignedUrl = - ApiEndpoint('/core/create-signed-url'); + static const ApiEndpoint createSignedUrl = ApiEndpoint('/create-signed-url'); /// Invoke a Large Language Model. - static const ApiEndpoint invokeLlm = ApiEndpoint('/core/invoke-llm'); + static const ApiEndpoint invokeLlm = ApiEndpoint('/invoke-llm'); /// Root for verification operations. - static const ApiEndpoint verifications = - ApiEndpoint('/core/verifications'); + static const ApiEndpoint verifications = ApiEndpoint('/verifications'); /// Get status of a verification job. static ApiEndpoint verificationStatus(String id) => - ApiEndpoint('/core/verifications/$id'); + ApiEndpoint('/verifications/$id'); /// Review a verification decision. static ApiEndpoint verificationReview(String id) => - ApiEndpoint('/core/verifications/$id/review'); + ApiEndpoint('/verifications/$id/review'); /// Retry a verification job. static ApiEndpoint verificationRetry(String id) => - ApiEndpoint('/core/verifications/$id/retry'); + ApiEndpoint('/verifications/$id/retry'); /// Transcribe audio to text for rapid orders. static const ApiEndpoint transcribeRapidOrder = - ApiEndpoint('/core/rapid-orders/transcribe'); + ApiEndpoint('/rapid-orders/transcribe'); /// Parse text to structured rapid order. static const ApiEndpoint parseRapidOrder = - ApiEndpoint('/core/rapid-orders/parse'); + ApiEndpoint('/rapid-orders/parse'); + + /// Combined transcribe + parse in a single call. + static const ApiEndpoint processRapidOrder = + ApiEndpoint('/rapid-orders/process'); } diff --git a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart index c98c780e..6955b964 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart @@ -105,6 +105,10 @@ abstract final class StaffEndpoints { /// Benefits. static const ApiEndpoint benefits = ApiEndpoint('/staff/profile/benefits'); + /// Benefits history. + static const ApiEndpoint benefitsHistory = + ApiEndpoint('/staff/profile/benefits/history'); + /// Time card. static const ApiEndpoint timeCard = ApiEndpoint('/staff/profile/time-card'); @@ -112,6 +116,10 @@ abstract final class StaffEndpoints { /// Privacy settings. static const ApiEndpoint privacy = ApiEndpoint('/staff/profile/privacy'); + /// Preferred locations. + static const ApiEndpoint locations = + ApiEndpoint('/staff/profile/locations'); + /// FAQs. static const ApiEndpoint faqs = ApiEndpoint('/staff/faqs'); @@ -177,4 +185,16 @@ abstract final class StaffEndpoints { /// Delete certificate by ID. static ApiEndpoint certificateDelete(String certificateId) => ApiEndpoint('/staff/profile/certificates/$certificateId'); + + /// Submit shift for approval. + static ApiEndpoint shiftSubmitForApproval(String shiftId) => + ApiEndpoint('/staff/shifts/$shiftId/submit-for-approval'); + + /// Location streams. + static const ApiEndpoint locationStreams = + ApiEndpoint('/staff/location-streams'); + + /// Register or delete device push token (POST to register, DELETE to remove). + static const ApiEndpoint devicesPushTokens = + ApiEndpoint('/staff/devices/push-tokens'); } diff --git a/apps/mobile/packages/core/lib/src/services/api_service/mixins/api_error_handler.dart b/apps/mobile/packages/core/lib/src/services/api_service/mixins/api_error_handler.dart index da98e982..c8666199 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/mixins/api_error_handler.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/mixins/api_error_handler.dart @@ -97,7 +97,7 @@ mixin ApiErrorHandler { ); case DioExceptionType.cancel: - return UnknownException( + return const UnknownException( technicalMessage: 'Request cancelled', ); diff --git a/apps/mobile/packages/core/lib/src/services/auth/auth_token_provider.dart b/apps/mobile/packages/core/lib/src/services/auth/auth_token_provider.dart new file mode 100644 index 00000000..b42d7620 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/auth/auth_token_provider.dart @@ -0,0 +1,11 @@ +/// Provides the current Firebase ID token for API authentication. +/// +/// Lives in core so feature packages can access auth tokens +/// without importing firebase_auth directly. +abstract interface class AuthTokenProvider { + /// Returns the current ID token, refreshing if expired. + /// + /// Pass [forceRefresh] to force a token refresh from Firebase. + /// Returns null if no user is signed in. + Future getIdToken({bool forceRefresh}); +} diff --git a/apps/mobile/packages/core/lib/src/services/auth/firebase_auth_service.dart b/apps/mobile/packages/core/lib/src/services/auth/firebase_auth_service.dart new file mode 100644 index 00000000..66063def --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/auth/firebase_auth_service.dart @@ -0,0 +1,258 @@ +import 'dart:async'; + +import 'package:firebase_auth/firebase_auth.dart' as firebase; + +import 'package:krow_domain/krow_domain.dart' + show + InvalidCredentialsException, + NetworkException, + SignInFailedException, + User, + UserStatus; + +/// Abstraction over Firebase Auth client-side operations. +/// +/// Provides phone-based and email-based authentication, sign-out, +/// auth state observation, and current user queries. Lives in core +/// so feature packages never import `firebase_auth` directly. +abstract interface class FirebaseAuthService { + /// Stream of the currently signed-in user mapped to a domain [User]. + /// + /// Emits `null` when the user signs out. + Stream get authStateChanges; + + /// Returns the current user's phone number, or `null` if unavailable. + String? get currentUserPhoneNumber; + + /// Returns the current user's UID, or `null` if not signed in. + String? get currentUserUid; + + /// Initiates phone number verification via Firebase Auth SDK. + /// + /// Returns a [Future] that completes with the verification ID when + /// the SMS code is sent. The [onAutoVerified] callback fires if the + /// device auto-retrieves the credential (Android only). + Future verifyPhoneNumber({ + required String phoneNumber, + void Function()? onAutoVerified, + }); + + /// Cancels any pending phone verification request. + void cancelPendingPhoneVerification(); + + /// Signs in with a phone auth credential built from + /// [verificationId] and [smsCode]. + /// + /// Returns the signed-in domain [User] or throws a domain exception. + Future signInWithPhoneCredential({ + required String verificationId, + required String smsCode, + }); + + /// Signs in with email and password via Firebase Auth SDK. + /// + /// Returns the Firebase UID on success or throws a domain exception. + Future signInWithEmailAndPassword({ + required String email, + required String password, + }); + + /// Signs out the current user from Firebase Auth locally. + Future signOut(); + + /// Returns the current user's Firebase ID token. + /// + /// Returns `null` if no user is signed in. + Future getIdToken(); +} + +/// Result of a phone credential sign-in. +/// +/// Contains the Firebase user's UID, phone number, and ID token +/// so the caller can proceed with V2 API verification without +/// importing `firebase_auth`. +class PhoneSignInResult { + /// Creates a [PhoneSignInResult]. + const PhoneSignInResult({ + required this.uid, + required this.phoneNumber, + required this.idToken, + }); + + /// The Firebase user UID. + final String uid; + + /// The phone number associated with the credential. + final String? phoneNumber; + + /// The Firebase ID token for the signed-in user. + final String? idToken; +} + +/// Firebase-backed implementation of [FirebaseAuthService]. +/// +/// Wraps the `firebase_auth` package so that feature packages +/// interact with Firebase Auth only through this core service. +class FirebaseAuthServiceImpl implements FirebaseAuthService { + /// Creates a [FirebaseAuthServiceImpl]. + /// + /// Optionally accepts a [firebase.FirebaseAuth] instance for testing. + FirebaseAuthServiceImpl({firebase.FirebaseAuth? auth}) + : _auth = auth ?? firebase.FirebaseAuth.instance; + + /// The Firebase Auth instance. + final firebase.FirebaseAuth _auth; + + /// Completer for the pending phone verification request. + Completer? _pendingVerification; + + @override + Stream get authStateChanges => + _auth.authStateChanges().map((firebase.User? firebaseUser) { + if (firebaseUser == null) { + return null; + } + return User( + id: firebaseUser.uid, + email: firebaseUser.email, + displayName: firebaseUser.displayName, + phone: firebaseUser.phoneNumber, + status: UserStatus.active, + ); + }); + + @override + String? get currentUserPhoneNumber => _auth.currentUser?.phoneNumber; + + @override + String? get currentUserUid => _auth.currentUser?.uid; + + @override + Future verifyPhoneNumber({ + required String phoneNumber, + void Function()? onAutoVerified, + }) async { + final Completer completer = Completer(); + _pendingVerification = completer; + + await _auth.verifyPhoneNumber( + phoneNumber: phoneNumber, + verificationCompleted: (firebase.PhoneAuthCredential credential) { + onAutoVerified?.call(); + }, + verificationFailed: (firebase.FirebaseAuthException e) { + if (!completer.isCompleted) { + if (e.code == 'network-request-failed' || + e.message?.contains('Unable to resolve host') == true) { + completer.completeError( + const NetworkException( + technicalMessage: 'Auth network failure', + ), + ); + } else { + completer.completeError( + SignInFailedException( + technicalMessage: 'Firebase ${e.code}: ${e.message}', + ), + ); + } + } + }, + codeSent: (String verificationId, _) { + if (!completer.isCompleted) { + completer.complete(verificationId); + } + }, + codeAutoRetrievalTimeout: (String verificationId) { + if (!completer.isCompleted) { + completer.complete(verificationId); + } + }, + ); + + return completer.future; + } + + @override + void cancelPendingPhoneVerification() { + final Completer? completer = _pendingVerification; + if (completer != null && !completer.isCompleted) { + completer.completeError(Exception('Phone verification cancelled.')); + } + _pendingVerification = null; + } + + @override + Future signInWithPhoneCredential({ + required String verificationId, + required String smsCode, + }) async { + final firebase.PhoneAuthCredential credential = + firebase.PhoneAuthProvider.credential( + verificationId: verificationId, + smsCode: smsCode, + ); + + final firebase.UserCredential userCredential; + try { + userCredential = await _auth.signInWithCredential(credential); + } on firebase.FirebaseAuthException catch (e) { + if (e.code == 'invalid-verification-code') { + throw const InvalidCredentialsException( + technicalMessage: 'Invalid OTP code entered.', + ); + } + rethrow; + } + + final firebase.User? firebaseUser = userCredential.user; + if (firebaseUser == null) { + throw const SignInFailedException( + technicalMessage: + 'Phone verification failed, no Firebase user received.', + ); + } + + final String? idToken = await firebaseUser.getIdToken(); + if (idToken == null) { + throw const SignInFailedException( + technicalMessage: 'Failed to obtain Firebase ID token.', + ); + } + + return PhoneSignInResult( + uid: firebaseUser.uid, + phoneNumber: firebaseUser.phoneNumber, + idToken: idToken, + ); + } + + @override + Future signInWithEmailAndPassword({ + required String email, + required String password, + }) async { + final firebase.UserCredential credential = + await _auth.signInWithEmailAndPassword(email: email, password: password); + + final firebase.User? firebaseUser = credential.user; + if (firebaseUser == null) { + throw const SignInFailedException( + technicalMessage: 'Local Firebase sign-in returned null user.', + ); + } + + return firebaseUser.uid; + } + + @override + Future signOut() async { + await _auth.signOut(); + } + + @override + Future getIdToken() async { + final firebase.User? user = _auth.currentUser; + return user?.getIdToken(); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/auth/firebase_auth_token_provider.dart b/apps/mobile/packages/core/lib/src/services/auth/firebase_auth_token_provider.dart new file mode 100644 index 00000000..de9f162a --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/auth/firebase_auth_token_provider.dart @@ -0,0 +1,15 @@ +import 'package:firebase_auth/firebase_auth.dart'; + +import 'package:krow_core/src/services/auth/auth_token_provider.dart'; + +/// Firebase-backed implementation of [AuthTokenProvider]. +/// +/// Delegates to [FirebaseAuth] to get the current user's +/// ID token. Must run in the main isolate (Firebase SDK requirement). +class FirebaseAuthTokenProvider implements AuthTokenProvider { + @override + Future getIdToken({bool forceRefresh = false}) async { + final User? user = FirebaseAuth.instance.currentUser; + return user?.getIdToken(forceRefresh); + } +} diff --git a/apps/mobile/packages/core/lib/src/utils/time_utils.dart b/apps/mobile/packages/core/lib/src/utils/time_utils.dart index 7340753c..0f5b7d8c 100644 --- a/apps/mobile/packages/core/lib/src/utils/time_utils.dart +++ b/apps/mobile/packages/core/lib/src/utils/time_utils.dart @@ -1,5 +1,45 @@ import 'package:intl/intl.dart'; +/// Converts a break duration label (e.g. `'MIN_30'`) to its value in minutes. +/// +/// Recognised labels: `MIN_10`, `MIN_15`, `MIN_30`, `MIN_45`, `MIN_60`. +/// Returns `0` for any unrecognised value (including `'NO_BREAK'`). +int breakMinutesFromLabel(String label) { + switch (label) { + case 'MIN_10': + return 10; + case 'MIN_15': + return 15; + case 'MIN_30': + return 30; + case 'MIN_45': + return 45; + case 'MIN_60': + return 60; + default: + return 0; + } +} + +/// Formats a [DateTime] to a `yyyy-MM-dd` date string. +/// +/// Example: `DateTime(2026, 3, 5)` -> `'2026-03-05'`. +String formatDateToIso(DateTime date) { + return '${date.year.toString().padLeft(4, '0')}-' + '${date.month.toString().padLeft(2, '0')}-' + '${date.day.toString().padLeft(2, '0')}'; +} + +/// Formats a [DateTime] to `HH:mm` (24-hour) time string. +/// +/// Converts to local time before formatting. +/// Example: a UTC DateTime of 14:30 in UTC-5 -> `'09:30'`. +String formatTimeHHmm(DateTime dt) { + final DateTime local = dt.toLocal(); + return '${local.hour.toString().padLeft(2, '0')}:' + '${local.minute.toString().padLeft(2, '0')}'; +} + /// Formats a time string (ISO 8601 or HH:mm) into 12-hour format /// (e.g. "9:00 AM"). /// 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 a7a83a54..4b63ce6b 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 @@ -12,6 +12,13 @@ "english": "English", "spanish": "Español" }, + "session": { + "expired_title": "Session Expired", + "expired_message": "Your session has expired. Please log in again to continue.", + "error_title": "Session Error", + "log_in": "Log In", + "log_out": "Log Out" + }, "settings": { "language": "Language", "change_language": "Change Language" @@ -665,7 +672,14 @@ "status": { "pending": "Pending", "submitted": "Submitted" - } + }, + "history_header": "HISTORY", + "no_history": "No history yet", + "show_all": "Show all", + "hours_accrued": "+${hours}h accrued", + "hours_used": "-${hours}h used", + "history_page_title": "$benefit History", + "loading_more": "Loading..." } }, "auto_match": { @@ -964,11 +978,15 @@ "retry": "Retry", "clock_in_anyway": "Clock In Anyway", "override_title": "Justification Required", - "override_desc": "Your location could not be verified. Please explain why you are clocking in without location verification.", + "override_desc": "Your location could not be verified. Please explain why you are proceeding without location verification.", "override_hint": "Enter your justification...", - "override_submit": "Clock In", + "override_submit": "Submit", "overridden_title": "Location Not Verified", - "overridden_desc": "You are clocking in without location verification. Your justification has been recorded." + "overridden_desc": "You are proceeding without location verification. Your justification has been recorded.", + "outside_work_area_warning": "You've moved away from the work area", + "outside_work_area_title": "You've moved away from the work area", + "outside_work_area_desc": "You are $distance away from your shift location. To clock out, provide a reason below.", + "clock_out_anyway": "Clock out anyway" } }, "availability": { @@ -1159,6 +1177,8 @@ "upload": { "instructions": "Please select a valid PDF file to upload.", "pdf_banner": "Only PDF files are accepted. Maximum file size is 10MB.", + "pdf_banner_title": "PDF files only", + "pdf_banner_description": "Upload a PDF document up to 10MB in size.", "file_not_found": "File not found.", "submit": "Submit Document", "select_pdf": "Select PDF File", @@ -1337,14 +1357,22 @@ "applying_dialog": { "title": "Applying" }, - "eligibility_requirements": "Eligibility Requirements" + "eligibility_requirements": "Eligibility Requirements", + "missing_certifications": "You are missing required certifications or documents to claim this shift. Please upload them to continue.", + "go_to_certificates": "Go to Certificates", + "shift_booked": "Shift successfully booked!", + "shift_not_found": "Shift not found", + "shift_declined_success": "Shift declined", + "complete_account_title": "Complete Your Account", + "complete_account_description": "Complete your account to book this shift and start earning" }, "my_shift_card": { "submit_for_approval": "Submit for Approval", "timesheet_submitted": "Timesheet submitted for client approval", "checked_in": "Checked in", "submitted": "SUBMITTED", - "ready_to_submit": "READY TO SUBMIT" + "ready_to_submit": "READY TO SUBMIT", + "submitting": "SUBMITTING..." }, "shift_location": { "could_not_open_maps": "Could not open maps" @@ -1457,11 +1485,14 @@ "shift": { "no_open_roles": "There are no open positions available for this shift.", "application_not_found": "Your application couldn't be found.", - "no_active_shift": "You don't have an active shift to clock out from." + "no_active_shift": "You don't have an active shift to clock out from.", + "not_found": "Shift not found. It may have been removed or is no longer available." }, "clock_in": { "location_verification_required": "Please wait for location verification before clocking in.", - "notes_required_for_timeout": "Please add a note explaining why your location can't be verified." + "notes_required_for_timeout": "Please add a note explaining why your location can't be verified.", + "already_clocked_in": "You're already clocked in to this shift.", + "already_clocked_out": "You've already clocked out of this shift." }, "generic": { "unknown": "Something went wrong. Please try again.", @@ -1762,7 +1793,9 @@ "workers": "Workers", "error_occurred": "An error occurred", "retry": "Retry", - "shifts": "Shifts" + "shifts": "Shifts", + "overall_coverage": "Overall Coverage", + "live_activity": "LIVE ACTIVITY" }, "calendar": { "prev_week": "\u2190 Prev Week", @@ -1771,7 +1804,9 @@ }, "stats": { "checked_in": "Checked In", - "en_route": "En Route" + "en_route": "En Route", + "on_site": "On Site", + "late": "Late" }, "alert": { "workers_running_late(count)": { @@ -1779,6 +1814,45 @@ "other": "$count workers are running late" }, "auto_backup_searching": "Auto-backup system is searching for replacements." + }, + "review": { + "title": "Rate this worker", + "subtitle": "Share your feedback", + "rating_labels": { + "poor": "Poor", + "fair": "Fair", + "good": "Good", + "great": "Great", + "excellent": "Excellent" + }, + "favorite_label": "Favorite", + "block_label": "Block", + "feedback_placeholder": "Share details about this worker's performance...", + "submit": "Submit Review", + "success": "Review submitted successfully", + "issue_flags": { + "late": "Late", + "uniform": "Uniform", + "misconduct": "Misconduct", + "no_show": "No Show", + "attitude": "Attitude", + "performance": "Performance", + "left_early": "Left Early" + } + }, + "cancel": { + "title": "Cancel Worker?", + "subtitle": "This cannot be undone", + "confirm_message": "Are you sure you want to cancel $name?", + "helper_text": "They will receive a cancellation notification. A replacement will be automatically requested.", + "reason_placeholder": "Reason for cancellation (optional)", + "keep_worker": "Keep Worker", + "confirm": "Yes, Cancel", + "success": "Worker cancelled. Searching for replacement." + }, + "actions": { + "rate": "Rate", + "cancel": "Cancel" } }, "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 22d14d50..731896fd 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 @@ -12,6 +12,13 @@ "english": "English", "spanish": "Español" }, + "session": { + "expired_title": "Sesión Expirada", + "expired_message": "Tu sesión ha expirado. Por favor inicia sesión de nuevo para continuar.", + "error_title": "Error de Sesión", + "log_in": "Iniciar Sesión", + "log_out": "Cerrar Sesión" + }, "settings": { "language": "Idioma", "change_language": "Cambiar Idioma" @@ -660,7 +667,14 @@ "status": { "pending": "Pendiente", "submitted": "Enviado" - } + }, + "history_header": "HISTORIAL", + "no_history": "Sin historial aún", + "show_all": "Ver todo", + "hours_accrued": "+${hours}h acumuladas", + "hours_used": "-${hours}h utilizadas", + "history_page_title": "Historial de $benefit", + "loading_more": "Cargando..." } }, "auto_match": { @@ -959,11 +973,15 @@ "retry": "Reintentar", "clock_in_anyway": "Registrar Entrada", "override_title": "Justificación Requerida", - "override_desc": "No se pudo verificar su ubicación. Explique por qué registra entrada sin verificación de ubicación.", + "override_desc": "No se pudo verificar su ubicación. Explique por qué continúa sin verificación de ubicación.", "override_hint": "Ingrese su justificación...", - "override_submit": "Registrar Entrada", + "override_submit": "Enviar", "overridden_title": "Ubicación No Verificada", - "overridden_desc": "Está registrando entrada sin verificación de ubicación. Su justificación ha sido registrada." + "overridden_desc": "Está continuando sin verificación de ubicación. Su justificación ha sido registrada.", + "outside_work_area_warning": "Te has alejado del área de trabajo", + "outside_work_area_title": "Te has alejado del área de trabajo", + "outside_work_area_desc": "Estás a $distance de la ubicación de tu turno. Para registrar tu salida, proporciona una razón a continuación.", + "clock_out_anyway": "Registrar salida de todos modos" } }, "availability": { @@ -1154,6 +1172,8 @@ "upload": { "instructions": "Por favor selecciona un archivo PDF válido para subir.", "pdf_banner": "Solo se aceptan archivos PDF. Tamaño máximo del archivo: 10MB.", + "pdf_banner_title": "Solo archivos PDF", + "pdf_banner_description": "Sube un documento PDF de hasta 10MB de tamaño.", "submit": "Enviar Documento", "select_pdf": "Seleccionar Archivo PDF", "attestation": "Certifico que este documento es genuino y válido.", @@ -1332,14 +1352,22 @@ "applying_dialog": { "title": "Solicitando" }, - "eligibility_requirements": "Requisitos de Elegibilidad" + "eligibility_requirements": "Requisitos de Elegibilidad", + "missing_certifications": "Te faltan certificaciones o documentos requeridos para reclamar este turno. Por favor, súbelos para continuar.", + "go_to_certificates": "Ir a Certificados", + "shift_booked": "¡Turno reservado con éxito!", + "shift_not_found": "Turno no encontrado", + "shift_declined_success": "Turno rechazado", + "complete_account_title": "Completa Tu Cuenta", + "complete_account_description": "Completa tu cuenta para reservar este turno y comenzar a ganar" }, "my_shift_card": { "submit_for_approval": "Enviar para Aprobación", "timesheet_submitted": "Hoja de tiempo enviada para aprobación del cliente", "checked_in": "Registrado", "submitted": "ENVIADO", - "ready_to_submit": "LISTO PARA ENVIAR" + "ready_to_submit": "LISTO PARA ENVIAR", + "submitting": "ENVIANDO..." }, "shift_location": { "could_not_open_maps": "No se pudo abrir mapas" @@ -1452,11 +1480,14 @@ "shift": { "no_open_roles": "No hay posiciones abiertas disponibles para este turno.", "application_not_found": "No se pudo encontrar tu solicitud.", - "no_active_shift": "No tienes un turno activo para registrar salida." + "no_active_shift": "No tienes un turno activo para registrar salida.", + "not_found": "Turno no encontrado. Puede haber sido eliminado o ya no está disponible." }, "clock_in": { "location_verification_required": "Por favor, espera la verificaci\u00f3n de ubicaci\u00f3n antes de registrar entrada.", - "notes_required_for_timeout": "Por favor, agrega una nota explicando por qu\u00e9 no se puede verificar tu ubicaci\u00f3n." + "notes_required_for_timeout": "Por favor, agrega una nota explicando por qu\u00e9 no se puede verificar tu ubicaci\u00f3n.", + "already_clocked_in": "Ya est\u00e1s registrado en este turno.", + "already_clocked_out": "Ya registraste tu salida de este turno." }, "generic": { "unknown": "Algo sali\u00f3 mal. Por favor, intenta de nuevo.", @@ -1762,7 +1793,9 @@ "workers": "Trabajadores", "error_occurred": "Ocurri\u00f3 un error", "retry": "Reintentar", - "shifts": "Turnos" + "shifts": "Turnos", + "overall_coverage": "Cobertura General", + "live_activity": "ACTIVIDAD EN VIVO" }, "calendar": { "prev_week": "\u2190 Semana Anterior", @@ -1771,7 +1804,9 @@ }, "stats": { "checked_in": "Registrado", - "en_route": "En Camino" + "en_route": "En Camino", + "on_site": "En Sitio", + "late": "Tarde" }, "alert": { "workers_running_late(count)": { @@ -1779,6 +1814,45 @@ "other": "$count trabajadores est\u00e1n llegando tarde" }, "auto_backup_searching": "El sistema de respaldo autom\u00e1tico est\u00e1 buscando reemplazos." + }, + "review": { + "title": "Calificar a este trabajador", + "subtitle": "Comparte tu opini\u00f3n", + "rating_labels": { + "poor": "Malo", + "fair": "Regular", + "good": "Bueno", + "great": "Muy Bueno", + "excellent": "Excelente" + }, + "favorite_label": "Favorito", + "block_label": "Bloquear", + "feedback_placeholder": "Comparte detalles sobre el desempe\u00f1o de este trabajador...", + "submit": "Enviar Rese\u00f1a", + "success": "Rese\u00f1a enviada exitosamente", + "issue_flags": { + "late": "Tarde", + "uniform": "Uniforme", + "misconduct": "Mala Conducta", + "no_show": "No Se Present\u00f3", + "attitude": "Actitud", + "performance": "Rendimiento", + "left_early": "Sali\u00f3 Temprano" + } + }, + "cancel": { + "title": "\u00bfCancelar Trabajador?", + "subtitle": "Esta acci\u00f3n no se puede deshacer", + "confirm_message": "\u00bfEst\u00e1s seguro de que deseas cancelar a $name?", + "helper_text": "Recibir\u00e1n una notificaci\u00f3n de cancelaci\u00f3n. Se solicitar\u00e1 un reemplazo autom\u00e1ticamente.", + "reason_placeholder": "Raz\u00f3n de la cancelaci\u00f3n (opcional)", + "keep_worker": "Mantener Trabajador", + "confirm": "S\u00ed, Cancelar", + "success": "Trabajador cancelado. Buscando reemplazo." + }, + "actions": { + "rate": "Calificar", + "cancel": "Cancelar" } }, "client_reports_common": { diff --git a/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart b/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart index 69e4282d..5f6d5388 100644 --- a/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart +++ b/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart @@ -124,6 +124,8 @@ String _translateShiftError(String errorType) { return t.errors.shift.application_not_found; case 'no_active_shift': return t.errors.shift.no_active_shift; + case 'not_found': + return t.errors.shift.not_found; default: return t.errors.generic.unknown; } diff --git a/apps/mobile/packages/design_system/lib/src/widgets/ui_chip.dart b/apps/mobile/packages/design_system/lib/src/widgets/ui_chip.dart index 09a781da..62af6cf1 100644 --- a/apps/mobile/packages/design_system/lib/src/widgets/ui_chip.dart +++ b/apps/mobile/packages/design_system/lib/src/widgets/ui_chip.dart @@ -82,6 +82,7 @@ class UiChip extends StatelessWidget { final Row content = Row( mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, children: [ if (leadingIcon != null) ...[ Icon(leadingIcon, size: iconSize, color: contentColor), 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 c2e47037..bcb8fa25 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 @@ -84,6 +84,7 @@ class UiNoticeBanner extends StatelessWidget { style: UiTypography.body2b.copyWith( color: titleColor ?? UiColors.primary, ), + overflow: TextOverflow.ellipsis, ), ], ], diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index 37569eec..62f8dd73 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -18,6 +18,7 @@ export 'src/entities/enums/invoice_status.dart'; export 'src/entities/enums/onboarding_status.dart'; export 'src/entities/enums/order_type.dart'; export 'src/entities/enums/payment_status.dart'; +export 'src/entities/enums/review_issue_flag.dart'; export 'src/entities/enums/shift_status.dart'; export 'src/entities/enums/staff_industry.dart'; export 'src/entities/enums/staff_skill.dart'; @@ -72,6 +73,7 @@ export 'src/entities/orders/recent_order.dart'; // Financial & Payroll export 'src/entities/benefits/benefit.dart'; +export 'src/entities/benefits/benefit_history.dart'; export 'src/entities/financial/invoice.dart'; export 'src/entities/financial/billing_account.dart'; export 'src/entities/financial/current_bill.dart'; diff --git a/apps/mobile/packages/domain/lib/src/core/models/device_location.dart b/apps/mobile/packages/domain/lib/src/core/models/device_location.dart index 0f83b3b7..8ee08be4 100644 --- a/apps/mobile/packages/domain/lib/src/core/models/device_location.dart +++ b/apps/mobile/packages/domain/lib/src/core/models/device_location.dart @@ -2,6 +2,14 @@ import 'package:equatable/equatable.dart'; /// Represents a geographic location obtained from the device. class DeviceLocation extends Equatable { + + /// Creates a [DeviceLocation] instance. + const DeviceLocation({ + required this.latitude, + required this.longitude, + required this.accuracy, + required this.timestamp, + }); /// Latitude in degrees. final double latitude; @@ -14,14 +22,6 @@ class DeviceLocation extends Equatable { /// Time when this location was determined. final DateTime timestamp; - /// Creates a [DeviceLocation] instance. - const DeviceLocation({ - required this.latitude, - required this.longitude, - required this.accuracy, - required this.timestamp, - }); - @override - List get props => [latitude, longitude, accuracy, timestamp]; + List get props => [latitude, longitude, accuracy, timestamp]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/benefits/benefit_history.dart b/apps/mobile/packages/domain/lib/src/entities/benefits/benefit_history.dart new file mode 100644 index 00000000..f9933a37 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/benefits/benefit_history.dart @@ -0,0 +1,100 @@ +import 'package:equatable/equatable.dart'; + +import 'package:krow_domain/src/entities/enums/benefit_status.dart'; + +/// A historical record of a staff benefit accrual period. +/// +/// Returned by `GET /staff/profile/benefits/history`. +class BenefitHistory extends Equatable { + /// Creates a [BenefitHistory] instance. + const BenefitHistory({ + required this.historyId, + required this.benefitId, + required this.benefitType, + required this.title, + required this.status, + required this.effectiveAt, + required this.trackedHours, + required this.targetHours, + this.endedAt, + this.notes, + }); + + /// Deserialises a [BenefitHistory] from a V2 API JSON map. + factory BenefitHistory.fromJson(Map json) { + return BenefitHistory( + historyId: json['historyId'] as String, + benefitId: json['benefitId'] as String, + benefitType: json['benefitType'] as String, + title: json['title'] as String, + status: BenefitStatus.fromJson(json['status'] as String?), + effectiveAt: DateTime.parse(json['effectiveAt'] as String), + endedAt: json['endedAt'] != null + ? DateTime.parse(json['endedAt'] as String) + : null, + trackedHours: (json['trackedHours'] as num).toInt(), + targetHours: (json['targetHours'] as num).toInt(), + notes: json['notes'] as String?, + ); + } + + /// Unique identifier for this history record. + final String historyId; + + /// The benefit this record belongs to. + final String benefitId; + + /// Type code (e.g. SICK_LEAVE, VACATION). + final String benefitType; + + /// Human-readable title. + final String title; + + /// Status of the benefit during this period. + final BenefitStatus status; + + /// When this benefit period became effective. + final DateTime effectiveAt; + + /// When this benefit period ended, or `null` if still active. + final DateTime? endedAt; + + /// Hours tracked during this period. + final int trackedHours; + + /// Target hours for this period. + final int targetHours; + + /// Optional notes about the accrual. + final String? notes; + + /// Serialises this [BenefitHistory] to a JSON map. + Map toJson() { + return { + 'historyId': historyId, + 'benefitId': benefitId, + 'benefitType': benefitType, + 'title': title, + 'status': status.toJson(), + 'effectiveAt': effectiveAt.toIso8601String(), + 'endedAt': endedAt?.toIso8601String(), + 'trackedHours': trackedHours, + 'targetHours': targetHours, + 'notes': notes, + }; + } + + @override + List get props => [ + historyId, + benefitId, + benefitType, + title, + status, + effectiveAt, + endedAt, + trackedHours, + targetHours, + notes, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/enums/review_issue_flag.dart b/apps/mobile/packages/domain/lib/src/entities/enums/review_issue_flag.dart new file mode 100644 index 00000000..4604b5d5 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/enums/review_issue_flag.dart @@ -0,0 +1,46 @@ +/// Issue flags that can be attached to a worker review. +/// +/// Maps to the allowed values for the `issue_flags` field in the +/// V2 coverage reviews endpoint. +enum ReviewIssueFlag { + /// Worker arrived late. + late('LATE'), + + /// Uniform violation. + uniform('UNIFORM'), + + /// Worker misconduct. + misconduct('MISCONDUCT'), + + /// Worker did not show up. + noShow('NO_SHOW'), + + /// Attitude issue. + attitude('ATTITUDE'), + + /// Performance issue. + performance('PERFORMANCE'), + + /// Worker left before shift ended. + leftEarly('LEFT_EARLY'), + + /// Fallback for unrecognised API values. + unknown('UNKNOWN'); + + const ReviewIssueFlag(this.value); + + /// The V2 API string representation. + final String value; + + /// Deserialises from a V2 API string with safe fallback. + static ReviewIssueFlag fromJson(String? value) { + if (value == null) return ReviewIssueFlag.unknown; + for (final ReviewIssueFlag flag in ReviewIssueFlag.values) { + if (flag.value == value) return flag; + } + return ReviewIssueFlag.unknown; + } + + /// Serialises to the V2 API string. + String toJson() => value; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/assigned_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/assigned_shift.dart index a41371be..11b27bc1 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/assigned_shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/assigned_shift.dart @@ -18,6 +18,10 @@ class AssignedShift extends Equatable { required this.startTime, required this.endTime, required this.hourlyRateCents, + required this.hourlyRate, + required this.totalRateCents, + required this.totalRate, + required this.clientName, required this.orderType, required this.status, }); @@ -33,6 +37,10 @@ class AssignedShift extends Equatable { startTime: DateTime.parse(json['startTime'] as String), endTime: DateTime.parse(json['endTime'] as String), hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, + hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0, + totalRateCents: json['totalRateCents'] as int? ?? 0, + totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0, + clientName: json['clientName'] as String? ?? '', orderType: OrderType.fromJson(json['orderType'] as String?), status: AssignmentStatus.fromJson(json['status'] as String?), ); @@ -62,6 +70,18 @@ class AssignedShift extends Equatable { /// Pay rate in cents per hour. final int hourlyRateCents; + /// Pay rate in dollars per hour. + final double hourlyRate; + + /// Total pay for this shift in cents. + final int totalRateCents; + + /// Total pay for this shift in dollars. + final double totalRate; + + /// Name of the client / business for this shift. + final String clientName; + /// Order type. final OrderType orderType; @@ -79,6 +99,10 @@ class AssignedShift extends Equatable { 'startTime': startTime.toIso8601String(), 'endTime': endTime.toIso8601String(), 'hourlyRateCents': hourlyRateCents, + 'hourlyRate': hourlyRate, + 'totalRateCents': totalRateCents, + 'totalRate': totalRate, + 'clientName': clientName, 'orderType': orderType.toJson(), 'status': status.toJson(), }; @@ -94,6 +118,10 @@ class AssignedShift extends Equatable { startTime, endTime, hourlyRateCents, + hourlyRate, + totalRateCents, + totalRate, + clientName, orderType, status, ]; diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart index 3d3e47e2..54f29d7d 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart @@ -12,10 +12,18 @@ class CompletedShift extends Equatable { required this.shiftId, required this.title, required this.location, + required this.clientName, required this.date, + required this.startTime, + required this.endTime, required this.minutesWorked, + required this.hourlyRateCents, + required this.hourlyRate, + required this.totalRateCents, + required this.totalRate, required this.paymentStatus, required this.status, + this.timesheetStatus, }); /// Deserialises from the V2 API JSON response. @@ -25,10 +33,22 @@ class CompletedShift extends Equatable { shiftId: json['shiftId'] as String, title: json['title'] as String? ?? '', location: json['location'] as String? ?? '', + clientName: json['clientName'] as String? ?? '', date: DateTime.parse(json['date'] as String), + startTime: json['startTime'] != null + ? DateTime.parse(json['startTime'] as String) + : DateTime.now(), + endTime: json['endTime'] != null + ? DateTime.parse(json['endTime'] as String) + : DateTime.now(), minutesWorked: json['minutesWorked'] as int? ?? 0, + hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, + hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0, + totalRateCents: json['totalRateCents'] as int? ?? 0, + totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0, paymentStatus: PaymentStatus.fromJson(json['paymentStatus'] as String?), status: AssignmentStatus.completed, + timesheetStatus: json['timesheetStatus'] as String?, ); } @@ -44,18 +64,42 @@ class CompletedShift extends Equatable { /// Human-readable location label. final String location; + /// Name of the client / business for this shift. + final String clientName; + /// The date the shift was worked. final DateTime date; + /// Scheduled start time. + final DateTime startTime; + + /// Scheduled end time. + final DateTime endTime; + /// Total minutes worked (regular + overtime). final int minutesWorked; + /// Pay rate in cents per hour. + final int hourlyRateCents; + + /// Pay rate in dollars per hour. + final double hourlyRate; + + /// Total pay for this shift in cents. + final int totalRateCents; + + /// Total pay for this shift in dollars. + final double totalRate; + /// Payment processing status. final PaymentStatus paymentStatus; /// Assignment status (should always be `completed` for this class). final AssignmentStatus status; + /// Timesheet status (e.g. `SUBMITTED`, `APPROVED`, `PAID`, or null). + final String? timesheetStatus; + /// Serialises to JSON. Map toJson() { return { @@ -63,9 +107,17 @@ class CompletedShift extends Equatable { 'shiftId': shiftId, 'title': title, 'location': location, + 'clientName': clientName, 'date': date.toIso8601String(), + 'startTime': startTime.toIso8601String(), + 'endTime': endTime.toIso8601String(), 'minutesWorked': minutesWorked, + 'hourlyRateCents': hourlyRateCents, + 'hourlyRate': hourlyRate, + 'totalRateCents': totalRateCents, + 'totalRate': totalRate, 'paymentStatus': paymentStatus.toJson(), + 'timesheetStatus': timesheetStatus, }; } @@ -75,8 +127,17 @@ class CompletedShift extends Equatable { shiftId, title, location, + clientName, date, + startTime, + endTime, minutesWorked, + hourlyRateCents, + hourlyRate, + totalRateCents, + totalRate, paymentStatus, + timesheetStatus, + status, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/open_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/open_shift.dart index 8481b343..856deef5 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/open_shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/open_shift.dart @@ -12,11 +12,13 @@ class OpenShift extends Equatable { required this.shiftId, required this.roleId, required this.roleName, + this.clientName = '', required this.location, required this.date, required this.startTime, required this.endTime, required this.hourlyRateCents, + required this.hourlyRate, required this.orderType, required this.instantBook, required this.requiredWorkerCount, @@ -28,11 +30,13 @@ class OpenShift extends Equatable { shiftId: json['shiftId'] as String, roleId: json['roleId'] as String, roleName: json['roleName'] as String, + clientName: json['clientName'] as String? ?? '', location: json['location'] as String? ?? '', date: DateTime.parse(json['date'] as String), startTime: DateTime.parse(json['startTime'] as String), endTime: DateTime.parse(json['endTime'] as String), hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, + hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0, orderType: OrderType.fromJson(json['orderType'] as String?), instantBook: json['instantBook'] as bool? ?? false, requiredWorkerCount: json['requiredWorkerCount'] as int? ?? 1, @@ -48,6 +52,9 @@ class OpenShift extends Equatable { /// Display name of the role. final String roleName; + /// Name of the client/business offering this shift. + final String clientName; + /// Human-readable location label. final String location; @@ -63,6 +70,9 @@ class OpenShift extends Equatable { /// Pay rate in cents per hour. final int hourlyRateCents; + /// Pay rate in dollars per hour. + final double hourlyRate; + /// Order type. final OrderType orderType; @@ -78,11 +88,13 @@ class OpenShift extends Equatable { 'shiftId': shiftId, 'roleId': roleId, 'roleName': roleName, + 'clientName': clientName, 'location': location, 'date': date.toIso8601String(), 'startTime': startTime.toIso8601String(), 'endTime': endTime.toIso8601String(), 'hourlyRateCents': hourlyRateCents, + 'hourlyRate': hourlyRate, 'orderType': orderType.toJson(), 'instantBook': instantBook, 'requiredWorkerCount': requiredWorkerCount, @@ -94,11 +106,13 @@ class OpenShift extends Equatable { shiftId, roleId, roleName, + clientName, location, date, startTime, endTime, hourlyRateCents, + hourlyRate, orderType, instantBook, requiredWorkerCount, diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart index 03d21a7b..8b45cf75 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart @@ -11,7 +11,7 @@ class Shift extends Equatable { /// Creates a [Shift]. const Shift({ required this.id, - required this.orderId, + this.orderId, required this.title, required this.status, required this.startsAt, @@ -25,19 +25,39 @@ class Shift extends Equatable { required this.requiredWorkers, required this.assignedWorkers, this.notes, + this.clockInMode, + this.allowClockInOverride, + this.nfcTagId, + this.clientName, + this.roleName, }); /// Deserialises from the V2 API JSON response. + /// + /// Supports both the standard shift JSON shape (`id`, `startsAt`, `endsAt`) + /// and the today-shifts endpoint shape (`shiftId`, `startTime`, `endTime`). factory Shift.fromJson(Map json) { + final String? clientName = json['clientName'] as String?; + final String? roleName = json['roleName'] as String?; + return Shift( - id: json['id'] as String, - orderId: json['orderId'] as String, - title: json['title'] as String? ?? '', + id: json['id'] as String? ?? json['shiftId'] as String, + orderId: json['orderId'] as String?, + title: json['title'] as String? ?? + roleName ?? + clientName ?? + '', status: ShiftStatus.fromJson(json['status'] as String?), - startsAt: DateTime.parse(json['startsAt'] as String), - endsAt: DateTime.parse(json['endsAt'] as String), + startsAt: DateTime.parse( + json['startsAt'] as String? ?? json['startTime'] as String, + ), + endsAt: DateTime.parse( + json['endsAt'] as String? ?? json['endTime'] as String, + ), timezone: json['timezone'] as String? ?? 'UTC', - locationName: json['locationName'] as String?, + locationName: json['locationName'] as String? ?? + json['locationAddress'] as String? ?? + json['location'] as String?, locationAddress: json['locationAddress'] as String?, latitude: parseDouble(json['latitude']), longitude: parseDouble(json['longitude']), @@ -45,14 +65,19 @@ class Shift extends Equatable { requiredWorkers: json['requiredWorkers'] as int? ?? 1, assignedWorkers: json['assignedWorkers'] as int? ?? 0, notes: json['notes'] as String?, + clockInMode: json['clockInMode'] as String?, + allowClockInOverride: json['allowClockInOverride'] as bool?, + nfcTagId: json['nfcTagId'] as String?, + clientName: clientName, + roleName: roleName, ); } /// The shift row id. final String id; - /// The parent order id. - final String orderId; + /// The parent order id (may be null for today-shifts endpoint). + final String? orderId; /// Display title. final String title; @@ -93,6 +118,21 @@ class Shift extends Equatable { /// Free-form notes for the shift. final String? notes; + /// Clock-in mode for this shift (`NFC_REQUIRED`, `GEO_REQUIRED`, `EITHER`). + final String? clockInMode; + + /// Whether the worker is allowed to override the clock-in method. + final bool? allowClockInOverride; + + /// NFC tag identifier for NFC-based clock-in. + final String? nfcTagId; + + /// Name of the client (business) this shift belongs to. + final String? clientName; + + /// Name of the role the worker is assigned for this shift. + final String? roleName; + /// Serialises to JSON. Map toJson() { return { @@ -111,6 +151,11 @@ class Shift extends Equatable { 'requiredWorkers': requiredWorkers, 'assignedWorkers': assignedWorkers, 'notes': notes, + 'clockInMode': clockInMode, + 'allowClockInOverride': allowClockInOverride, + 'nfcTagId': nfcTagId, + 'clientName': clientName, + 'roleName': roleName, }; } @@ -140,5 +185,10 @@ class Shift extends Equatable { requiredWorkers, assignedWorkers, notes, + clockInMode, + allowClockInOverride, + nfcTagId, + clientName, + roleName, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart index 793dc07e..c4082982 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart @@ -3,6 +3,7 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/src/entities/enums/application_status.dart'; import 'package:krow_domain/src/entities/enums/assignment_status.dart'; import 'package:krow_domain/src/entities/enums/order_type.dart'; +import 'package:krow_domain/src/entities/shifts/shift.dart'; /// Full detail view of a shift for the staff member. /// @@ -18,17 +19,27 @@ class ShiftDetail extends Equatable { this.description, required this.location, this.address, + required this.clientName, + this.latitude, + this.longitude, required this.date, required this.startTime, required this.endTime, required this.roleId, required this.roleName, required this.hourlyRateCents, + required this.hourlyRate, + required this.totalRateCents, + required this.totalRate, required this.orderType, required this.requiredCount, required this.confirmedCount, this.assignmentStatus, this.applicationStatus, + this.clockInMode, + required this.allowClockInOverride, + this.geofenceRadiusMeters, + this.nfcTagId, }); /// Deserialises from the V2 API JSON response. @@ -39,12 +50,18 @@ class ShiftDetail extends Equatable { description: json['description'] as String?, location: json['location'] as String? ?? '', address: json['address'] as String?, + clientName: json['clientName'] as String? ?? '', + latitude: Shift.parseDouble(json['latitude']), + longitude: Shift.parseDouble(json['longitude']), date: DateTime.parse(json['date'] as String), startTime: DateTime.parse(json['startTime'] as String), endTime: DateTime.parse(json['endTime'] as String), roleId: json['roleId'] as String, roleName: json['roleName'] as String, hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, + hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0, + totalRateCents: json['totalRateCents'] as int? ?? 0, + totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0, orderType: OrderType.fromJson(json['orderType'] as String?), requiredCount: json['requiredCount'] as int? ?? 1, confirmedCount: json['confirmedCount'] as int? ?? 0, @@ -54,6 +71,10 @@ class ShiftDetail extends Equatable { applicationStatus: json['applicationStatus'] != null ? ApplicationStatus.fromJson(json['applicationStatus'] as String?) : null, + clockInMode: json['clockInMode'] as String?, + allowClockInOverride: json['allowClockInOverride'] as bool? ?? false, + geofenceRadiusMeters: json['geofenceRadiusMeters'] as int?, + nfcTagId: json['nfcTagId'] as String?, ); } @@ -72,6 +93,15 @@ class ShiftDetail extends Equatable { /// Street address of the shift location. final String? address; + /// Name of the client / business for this shift. + final String clientName; + + /// Latitude for map display and geofence validation. + final double? latitude; + + /// Longitude for map display and geofence validation. + final double? longitude; + /// Date of the shift (same as startTime, kept for display grouping). final DateTime date; @@ -90,6 +120,15 @@ class ShiftDetail extends Equatable { /// Pay rate in cents per hour. final int hourlyRateCents; + /// Pay rate in dollars per hour. + final double hourlyRate; + + /// Total pay for this shift in cents. + final int totalRateCents; + + /// Total pay for this shift in dollars. + final double totalRate; + /// Order type. final OrderType orderType; @@ -105,6 +144,26 @@ class ShiftDetail extends Equatable { /// Current worker's application status, if applied. final ApplicationStatus? applicationStatus; + /// Clock-in mode for this shift (`NFC_REQUIRED`, `GEO_REQUIRED`, `EITHER`). + final String? clockInMode; + + /// Whether the worker is allowed to override the clock-in method. + final bool allowClockInOverride; + + /// Geofence radius in meters for clock-in validation. + final int? geofenceRadiusMeters; + + /// NFC tag identifier for NFC-based clock-in. + final String? nfcTagId; + + /// Duration of the shift in hours. + double get durationHours { + return endTime.difference(startTime).inMinutes / 60; + } + + /// Estimated total pay in dollars. + double get estimatedTotal => hourlyRate * durationHours; + /// Serialises to JSON. Map toJson() { return { @@ -113,17 +172,27 @@ class ShiftDetail extends Equatable { 'description': description, 'location': location, 'address': address, + 'clientName': clientName, + 'latitude': latitude, + 'longitude': longitude, 'date': date.toIso8601String(), 'startTime': startTime.toIso8601String(), 'endTime': endTime.toIso8601String(), 'roleId': roleId, 'roleName': roleName, 'hourlyRateCents': hourlyRateCents, + 'hourlyRate': hourlyRate, + 'totalRateCents': totalRateCents, + 'totalRate': totalRate, 'orderType': orderType.toJson(), 'requiredCount': requiredCount, 'confirmedCount': confirmedCount, 'assignmentStatus': assignmentStatus?.toJson(), 'applicationStatus': applicationStatus?.toJson(), + 'clockInMode': clockInMode, + 'allowClockInOverride': allowClockInOverride, + 'geofenceRadiusMeters': geofenceRadiusMeters, + 'nfcTagId': nfcTagId, }; } @@ -134,16 +203,26 @@ class ShiftDetail extends Equatable { description, location, address, + clientName, + latitude, + longitude, date, startTime, endTime, roleId, roleName, hourlyRateCents, + hourlyRate, + totalRateCents, + totalRate, orderType, requiredCount, confirmedCount, assignmentStatus, applicationStatus, + clockInMode, + allowClockInOverride, + geofenceRadiusMeters, + nfcTagId, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/today_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/today_shift.dart index e1520964..01248ff3 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/today_shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/today_shift.dart @@ -17,6 +17,12 @@ class TodayShift extends Equatable { required this.startTime, required this.endTime, required this.attendanceStatus, + this.clientName = '', + this.hourlyRateCents = 0, + this.hourlyRate = 0.0, + this.totalRateCents = 0, + this.totalRate = 0.0, + this.locationAddress, this.clockInAt, }); @@ -30,6 +36,12 @@ class TodayShift extends Equatable { startTime: DateTime.parse(json['startTime'] as String), endTime: DateTime.parse(json['endTime'] as String), attendanceStatus: AttendanceStatusType.fromJson(json['attendanceStatus'] as String?), + clientName: json['clientName'] as String? ?? '', + hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, + hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0, + totalRateCents: json['totalRateCents'] as int? ?? 0, + totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0, + locationAddress: json['locationAddress'] as String?, clockInAt: json['clockInAt'] != null ? DateTime.parse(json['clockInAt'] as String) : null, @@ -48,6 +60,24 @@ class TodayShift extends Equatable { /// Human-readable location label (clock-point or shift location). final String location; + /// Name of the client / business for this shift. + final String clientName; + + /// Pay rate in cents per hour. + final int hourlyRateCents; + + /// Pay rate in dollars per hour. + final double hourlyRate; + + /// Total pay for this shift in cents. + final int totalRateCents; + + /// Total pay for this shift in dollars. + final double totalRate; + + /// Full street address of the shift location, if available. + final String? locationAddress; + /// Scheduled start time. final DateTime startTime; @@ -67,6 +97,12 @@ class TodayShift extends Equatable { 'shiftId': shiftId, 'roleName': roleName, 'location': location, + 'clientName': clientName, + 'hourlyRateCents': hourlyRateCents, + 'hourlyRate': hourlyRate, + 'totalRateCents': totalRateCents, + 'totalRate': totalRate, + 'locationAddress': locationAddress, 'startTime': startTime.toIso8601String(), 'endTime': endTime.toIso8601String(), 'attendanceStatus': attendanceStatus.toJson(), @@ -80,6 +116,12 @@ class TodayShift extends Equatable { shiftId, roleName, location, + clientName, + hourlyRateCents, + hourlyRate, + totalRateCents, + totalRate, + locationAddress, startTime, endTime, attendanceStatus, diff --git a/apps/mobile/packages/features/client/authentication/lib/client_authentication.dart b/apps/mobile/packages/features/client/authentication/lib/client_authentication.dart index 9a7a5670..d54e17bd 100644 --- a/apps/mobile/packages/features/client/authentication/lib/client_authentication.dart +++ b/apps/mobile/packages/features/client/authentication/lib/client_authentication.dart @@ -33,7 +33,10 @@ class ClientAuthenticationModule extends Module { void binds(Injector i) { // Repositories i.addLazySingleton( - () => AuthRepositoryImpl(apiService: i.get()), + () => AuthRepositoryImpl( + apiService: i.get(), + firebaseAuthService: i.get(), + ), ); // UseCases diff --git a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index 908b57f2..a09a78fc 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -1,7 +1,6 @@ import 'dart:developer' as developer; import 'package:client_authentication/src/domain/repositories/auth_repository_interface.dart'; -import 'package:firebase_auth/firebase_auth.dart' as firebase; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart' show @@ -10,7 +9,6 @@ import 'package:krow_domain/krow_domain.dart' AppException, BaseApiService, ClientSession, - InvalidCredentialsException, NetworkException, PasswordMismatchException, SignInFailedException, @@ -21,20 +19,23 @@ import 'package:krow_domain/krow_domain.dart' /// Production implementation of the [AuthRepositoryInterface] for the client app. /// -/// Uses Firebase Auth client-side for sign-in (to maintain local auth state for -/// the [AuthInterceptor]), then calls V2 `GET /auth/session` to retrieve -/// business context. Sign-up provisioning (tenant, business, memberships) is -/// handled entirely server-side by the V2 API. +/// Uses [FirebaseAuthService] from core for local Firebase sign-in (to maintain +/// local auth state for the [AuthInterceptor]), then calls V2 `GET /auth/session` +/// to retrieve business context. Sign-up provisioning (tenant, business, +/// memberships) is handled entirely server-side by the V2 API. class AuthRepositoryImpl implements AuthRepositoryInterface { - /// Creates an [AuthRepositoryImpl] with the given [BaseApiService]. - AuthRepositoryImpl({required BaseApiService apiService}) - : _apiService = apiService; + /// Creates an [AuthRepositoryImpl] with the given dependencies. + AuthRepositoryImpl({ + required BaseApiService apiService, + required FirebaseAuthService firebaseAuthService, + }) : _apiService = apiService, + _firebaseAuthService = firebaseAuthService; /// The V2 API service for backend calls. final BaseApiService _apiService; - /// Firebase Auth instance for client-side sign-in/sign-up. - firebase.FirebaseAuth get _auth => firebase.FirebaseAuth.instance; + /// Core Firebase Auth service abstraction. + final FirebaseAuthService _firebaseAuthService; @override Future signInWithEmail({ @@ -42,38 +43,26 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { required String password, }) async { try { - // Step 1: Call V2 sign-in endpoint — server handles Firebase Auth + // Step 1: Call V2 sign-in endpoint -- server handles Firebase Auth // via Identity Toolkit and returns a full auth envelope. final ApiResponse response = await _apiService.post( AuthEndpoints.clientSignIn, - data: { - 'email': email, - 'password': password, - }, + data: {'email': email, 'password': password}, ); - final Map body = - response.data as Map; + final Map body = response.data as Map; // Step 2: Sign in locally so AuthInterceptor can attach Bearer tokens // to subsequent requests. The V2 API already validated credentials, so // email/password sign-in establishes the local Firebase Auth state. - final firebase.UserCredential credential = - await _auth.signInWithEmailAndPassword( + await _firebaseAuthService.signInWithEmailAndPassword( email: email, password: password, ); - final firebase.User? firebaseUser = credential.user; - if (firebaseUser == null) { - throw const SignInFailedException( - technicalMessage: 'Local Firebase sign-in failed after V2 sign-in', - ); - } - // Step 3: Populate session store from the V2 auth envelope directly // (no need for a separate GET /auth/session call). - return _populateStoreFromAuthEnvelope(body, firebaseUser, email); + return _populateStoreFromAuthEnvelope(body, email); } on AppException { rethrow; } catch (e) { @@ -106,38 +95,34 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // Step 2: Sign in locally to Firebase Auth so AuthInterceptor works // for subsequent requests. The V2 API already created the Firebase // account, so this should succeed. - final firebase.UserCredential credential = - await _auth.signInWithEmailAndPassword( - email: email, - password: password, - ); - - final firebase.User? firebaseUser = credential.user; - if (firebaseUser == null) { + try { + await _firebaseAuthService.signInWithEmailAndPassword( + email: email, + password: password, + ); + } on SignInFailedException { throw const SignUpFailedException( technicalMessage: 'Local Firebase sign-in failed after V2 sign-up', ); } // Step 3: Populate store from the sign-up response envelope. - return _populateStoreFromAuthEnvelope(body, firebaseUser, email); - } on firebase.FirebaseAuthException catch (e) { - if (e.code == 'email-already-in-use') { - throw AccountExistsException( - technicalMessage: 'Firebase: ${e.message}', - ); - } else if (e.code == 'weak-password') { - throw WeakPasswordException(technicalMessage: 'Firebase: ${e.message}'); - } else if (e.code == 'network-request-failed') { - throw NetworkException(technicalMessage: 'Firebase: ${e.message}'); - } else { - throw SignUpFailedException( - technicalMessage: 'Firebase auth error: ${e.message}', - ); - } + return _populateStoreFromAuthEnvelope(body, email); } on AppException { rethrow; } catch (e) { + // Map common Firebase-originated errors from the V2 API response + // to domain exceptions. + final String errorMessage = e.toString(); + if (errorMessage.contains('EMAIL_EXISTS') || + errorMessage.contains('email-already-in-use')) { + throw AccountExistsException(technicalMessage: errorMessage); + } else if (errorMessage.contains('WEAK_PASSWORD') || + errorMessage.contains('weak-password')) { + throw WeakPasswordException(technicalMessage: errorMessage); + } else if (errorMessage.contains('network-request-failed')) { + throw NetworkException(technicalMessage: errorMessage); + } throw SignUpFailedException(technicalMessage: 'Unexpected error: $e'); } } @@ -155,16 +140,13 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // Step 1: Call V2 sign-out endpoint for server-side token revocation. await _apiService.post(AuthEndpoints.clientSignOut); } catch (e) { - developer.log( - 'V2 sign-out request failed: $e', - name: 'AuthRepository', - ); + developer.log('V2 sign-out request failed: $e', name: 'AuthRepository'); // Continue with local sign-out even if server-side fails. } try { - // Step 2: Sign out from local Firebase Auth. - await _auth.signOut(); + // Step 2: Sign out from local Firebase Auth via core service. + await _firebaseAuthService.signOut(); } catch (e) { throw Exception('Error signing out locally: $e'); } @@ -181,7 +163,6 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { /// returns a domain [User]. User _populateStoreFromAuthEnvelope( Map envelope, - firebase.User firebaseUser, String fallbackEmail, ) { final Map? userJson = @@ -202,14 +183,15 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { 'userId': userJson['id'] ?? userJson['userId'], }, }; - final ClientSession clientSession = - ClientSession.fromJson(normalisedEnvelope); + final ClientSession clientSession = ClientSession.fromJson( + normalisedEnvelope, + ); ClientSessionStore.instance.setSession(clientSession); } - final String userId = - userJson?['id'] as String? ?? firebaseUser.uid; - final String? email = userJson?['email'] as String? ?? fallbackEmail; + final String userId = userJson?['id'] as String? ?? + (_firebaseAuthService.currentUserUid ?? ''); + final String email = userJson?['email'] as String? ?? fallbackEmail; return User( id: userId, diff --git a/apps/mobile/packages/features/client/authentication/pubspec.yaml b/apps/mobile/packages/features/client/authentication/pubspec.yaml index 4db9ded0..cfe77594 100644 --- a/apps/mobile/packages/features/client/authentication/pubspec.yaml +++ b/apps/mobile/packages/features/client/authentication/pubspec.yaml @@ -14,7 +14,6 @@ dependencies: flutter_bloc: ^8.1.0 flutter_modular: ^6.3.0 equatable: ^2.0.5 - firebase_auth: ^6.1.2 # Architecture Packages design_system: 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 02a4bb6c..052648ea 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 @@ -3,7 +3,7 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:billing/src/data/repositories_impl/billing_repository_impl.dart'; -import 'package:billing/src/domain/repositories/billing_repository.dart'; +import 'package:billing/src/domain/repositories/billing_repository_interface.dart'; import 'package:billing/src/domain/usecases/approve_invoice.dart'; import 'package:billing/src/domain/usecases/dispute_invoice.dart'; import 'package:billing/src/domain/usecases/get_bank_accounts.dart'; @@ -29,8 +29,8 @@ class BillingModule extends Module { @override void binds(Injector i) { // Repositories - i.addLazySingleton( - () => BillingRepositoryImpl(apiService: i.get()), + i.addLazySingleton( + () => BillingRepositoryInterfaceImpl(apiService: i.get()), ); // Use Cases diff --git a/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart b/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart index 64027bd7..ba3dd517 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart @@ -1,14 +1,14 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:billing/src/domain/repositories/billing_repository.dart'; +import 'package:billing/src/domain/repositories/billing_repository_interface.dart'; -/// Implementation of [BillingRepository] using the V2 REST API. +/// Implementation of [BillingRepositoryInterface] using the V2 REST API. /// /// All backend calls go through [BaseApiService] with [ClientEndpoints]. -class BillingRepositoryImpl implements BillingRepository { - /// Creates a [BillingRepositoryImpl]. - BillingRepositoryImpl({required BaseApiService apiService}) +class BillingRepositoryInterfaceImpl implements BillingRepositoryInterface { + /// Creates a [BillingRepositoryInterfaceImpl]. + BillingRepositoryInterfaceImpl({required BaseApiService apiService}) : _apiService = apiService; /// The API service used for all HTTP requests. diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository_interface.dart similarity index 96% rename from apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart rename to apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository_interface.dart index 4a229926..53f98c0e 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository_interface.dart @@ -5,7 +5,7 @@ import 'package:krow_domain/krow_domain.dart'; /// This interface defines the contract for accessing billing-related data, /// acting as a boundary between the Domain and Data layers. /// It allows the Domain layer to remain independent of specific data sources. -abstract class BillingRepository { +abstract class BillingRepositoryInterface { /// Fetches bank accounts associated with the business. Future> getBankAccounts(); diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/approve_invoice.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/approve_invoice.dart index 7da6b1e0..5c3c6575 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/approve_invoice.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/approve_invoice.dart @@ -1,6 +1,6 @@ import 'package:krow_core/core.dart'; -import 'package:billing/src/domain/repositories/billing_repository.dart'; +import 'package:billing/src/domain/repositories/billing_repository_interface.dart'; /// Use case for approving an invoice. class ApproveInvoiceUseCase extends UseCase { @@ -8,7 +8,7 @@ class ApproveInvoiceUseCase extends UseCase { ApproveInvoiceUseCase(this._repository); /// The billing repository. - final BillingRepository _repository; + final BillingRepositoryInterface _repository; @override Future call(String input) => _repository.approveInvoice(input); diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/dispute_invoice.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/dispute_invoice.dart index baac7e47..b1bc7979 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/dispute_invoice.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/dispute_invoice.dart @@ -1,6 +1,6 @@ import 'package:krow_core/core.dart'; -import 'package:billing/src/domain/repositories/billing_repository.dart'; +import 'package:billing/src/domain/repositories/billing_repository_interface.dart'; /// Params for [DisputeInvoiceUseCase]. class DisputeInvoiceParams { @@ -20,7 +20,7 @@ class DisputeInvoiceUseCase extends UseCase { DisputeInvoiceUseCase(this._repository); /// The billing repository. - final BillingRepository _repository; + final BillingRepositoryInterface _repository; @override Future call(DisputeInvoiceParams input) => diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_bank_accounts.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_bank_accounts.dart index 39ffba24..5cc64584 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_bank_accounts.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_bank_accounts.dart @@ -1,7 +1,7 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:billing/src/domain/repositories/billing_repository.dart'; +import 'package:billing/src/domain/repositories/billing_repository_interface.dart'; /// Use case for fetching the bank accounts associated with the business. class GetBankAccountsUseCase extends NoInputUseCase> { @@ -9,7 +9,7 @@ class GetBankAccountsUseCase extends NoInputUseCase> { GetBankAccountsUseCase(this._repository); /// The billing repository. - final BillingRepository _repository; + final BillingRepositoryInterface _repository; @override Future> call() => _repository.getBankAccounts(); diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_current_bill_amount.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_current_bill_amount.dart index 39f4737b..9a7e5543 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_current_bill_amount.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_current_bill_amount.dart @@ -1,16 +1,16 @@ import 'package:krow_core/core.dart'; -import 'package:billing/src/domain/repositories/billing_repository.dart'; +import 'package:billing/src/domain/repositories/billing_repository_interface.dart'; /// Use case for fetching the current bill amount in cents. /// -/// Delegates data retrieval to the [BillingRepository]. +/// Delegates data retrieval to the [BillingRepositoryInterface]. class GetCurrentBillAmountUseCase extends NoInputUseCase { /// Creates a [GetCurrentBillAmountUseCase]. GetCurrentBillAmountUseCase(this._repository); /// The billing repository. - final BillingRepository _repository; + final BillingRepositoryInterface _repository; @override Future call() => _repository.getCurrentBillCents(); diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_invoice_history.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_invoice_history.dart index ab84cf5d..a156ef6f 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_invoice_history.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_invoice_history.dart @@ -1,7 +1,7 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:billing/src/domain/repositories/billing_repository.dart'; +import 'package:billing/src/domain/repositories/billing_repository_interface.dart'; /// Use case for fetching the invoice history. /// @@ -11,7 +11,7 @@ class GetInvoiceHistoryUseCase extends NoInputUseCase> { GetInvoiceHistoryUseCase(this._repository); /// The billing repository. - final BillingRepository _repository; + final BillingRepositoryInterface _repository; @override Future> call() => _repository.getInvoiceHistory(); diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_pending_invoices.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_pending_invoices.dart index fb8a7e9d..ea5fed85 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_pending_invoices.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_pending_invoices.dart @@ -1,7 +1,7 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:billing/src/domain/repositories/billing_repository.dart'; +import 'package:billing/src/domain/repositories/billing_repository_interface.dart'; /// Use case for fetching the pending invoices. /// @@ -11,7 +11,7 @@ class GetPendingInvoicesUseCase extends NoInputUseCase> { GetPendingInvoicesUseCase(this._repository); /// The billing repository. - final BillingRepository _repository; + final BillingRepositoryInterface _repository; @override Future> call() => _repository.getPendingInvoices(); diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_savings_amount.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_savings_amount.dart index baedf222..68b622ae 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_savings_amount.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_savings_amount.dart @@ -1,16 +1,16 @@ import 'package:krow_core/core.dart'; -import 'package:billing/src/domain/repositories/billing_repository.dart'; +import 'package:billing/src/domain/repositories/billing_repository_interface.dart'; /// Use case for fetching the savings amount in cents. /// -/// Delegates data retrieval to the [BillingRepository]. +/// Delegates data retrieval to the [BillingRepositoryInterface]. class GetSavingsAmountUseCase extends NoInputUseCase { /// Creates a [GetSavingsAmountUseCase]. GetSavingsAmountUseCase(this._repository); /// The billing repository. - final BillingRepository _repository; + final BillingRepositoryInterface _repository; @override Future call() => _repository.getSavingsCents(); diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_spending_breakdown.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_spending_breakdown.dart index 0e01534a..4a244818 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_spending_breakdown.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_spending_breakdown.dart @@ -1,7 +1,7 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:billing/src/domain/repositories/billing_repository.dart'; +import 'package:billing/src/domain/repositories/billing_repository_interface.dart'; /// Parameters for [GetSpendBreakdownUseCase]. class SpendBreakdownParams { @@ -20,14 +20,14 @@ class SpendBreakdownParams { /// Use case for fetching the spending breakdown by category. /// -/// Delegates data retrieval to the [BillingRepository]. +/// Delegates data retrieval to the [BillingRepositoryInterface]. class GetSpendBreakdownUseCase extends UseCase> { /// Creates a [GetSpendBreakdownUseCase]. GetSpendBreakdownUseCase(this._repository); /// The billing repository. - final BillingRepository _repository; + final BillingRepositoryInterface _repository; @override Future> call(SpendBreakdownParams input) => diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart index 3543571a..0f5aa09a 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart @@ -1,5 +1,3 @@ -import 'dart:developer' as developer; - import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -14,6 +12,9 @@ import 'package:billing/src/presentation/blocs/billing_event.dart'; import 'package:billing/src/presentation/blocs/billing_state.dart'; /// BLoC for managing billing state and data loading. +/// +/// Fetches billing summary data (current bill, savings, invoices, +/// spend breakdown, bank accounts) and manages period tab selection. class BillingBloc extends Bloc with BlocErrorHandler { /// Creates a [BillingBloc] with the given use cases. @@ -35,64 +36,97 @@ class BillingBloc extends Bloc on(_onPeriodChanged); } + /// Use case for fetching bank accounts. final GetBankAccountsUseCase _getBankAccounts; + + /// Use case for fetching the current bill amount. final GetCurrentBillAmountUseCase _getCurrentBillAmount; + + /// Use case for fetching the savings amount. final GetSavingsAmountUseCase _getSavingsAmount; + + /// Use case for fetching pending invoices. final GetPendingInvoicesUseCase _getPendingInvoices; + + /// Use case for fetching invoice history. final GetInvoiceHistoryUseCase _getInvoiceHistory; + + /// Use case for fetching spending breakdown. final GetSpendBreakdownUseCase _getSpendBreakdown; - /// Executes [loader] and returns null on failure, logging the error. - Future _loadSafe(Future Function() loader) async { - try { - return await loader(); - } catch (e, stackTrace) { - developer.log( - 'Partial billing load failed: $e', - name: 'BillingBloc', - error: e, - stackTrace: stackTrace, - ); - return null; - } - } - + /// Loads all billing data concurrently. + /// + /// Uses [handleError] to surface errors to the UI via state + /// instead of silently swallowing them. Individual data fetches + /// use [handleErrorWithResult] so partial failures populate + /// with defaults rather than failing the entire load. Future _onLoadStarted( BillingLoadStarted event, Emitter emit, ) async { - emit(state.copyWith(status: BillingStatus.loading)); + await handleError( + emit: emit.call, + action: () async { + emit(state.copyWith(status: BillingStatus.loading)); - final SpendBreakdownParams spendParams = _dateRangeFor(state.periodTab); + final SpendBreakdownParams spendParams = + _dateRangeFor(state.periodTab); - final List results = await Future.wait( - >[ - _loadSafe(() => _getCurrentBillAmount.call()), - _loadSafe(() => _getSavingsAmount.call()), - _loadSafe>(() => _getPendingInvoices.call()), - _loadSafe>(() => _getInvoiceHistory.call()), - _loadSafe>(() => _getSpendBreakdown.call(spendParams)), - _loadSafe>(() => _getBankAccounts.call()), - ], - ); + final List results = await Future.wait( + >[ + handleErrorWithResult( + action: () => _getCurrentBillAmount.call(), + onError: (_) {}, + ), + handleErrorWithResult( + action: () => _getSavingsAmount.call(), + onError: (_) {}, + ), + handleErrorWithResult>( + action: () => _getPendingInvoices.call(), + onError: (_) {}, + ), + handleErrorWithResult>( + action: () => _getInvoiceHistory.call(), + onError: (_) {}, + ), + handleErrorWithResult>( + action: () => _getSpendBreakdown.call(spendParams), + onError: (_) {}, + ), + handleErrorWithResult>( + action: () => _getBankAccounts.call(), + onError: (_) {}, + ), + ], + ); - final int? currentBillCents = results[0] as int?; - final int? savingsCents = results[1] as int?; - final List? pendingInvoices = results[2] as List?; - final List? invoiceHistory = results[3] as List?; - final List? spendBreakdown = results[4] as List?; - final List? bankAccounts = - results[5] as List?; + final int? currentBillCents = results[0] as int?; + final int? savingsCents = results[1] as int?; + final List? pendingInvoices = + results[2] as List?; + final List? invoiceHistory = + results[3] as List?; + final List? spendBreakdown = + results[4] as List?; + final List? bankAccounts = + results[5] as List?; - emit( - state.copyWith( - status: BillingStatus.success, - currentBillCents: currentBillCents ?? state.currentBillCents, - savingsCents: savingsCents ?? state.savingsCents, - pendingInvoices: pendingInvoices ?? state.pendingInvoices, - invoiceHistory: invoiceHistory ?? state.invoiceHistory, - spendBreakdown: spendBreakdown ?? state.spendBreakdown, - bankAccounts: bankAccounts ?? state.bankAccounts, + emit( + state.copyWith( + status: BillingStatus.success, + currentBillCents: currentBillCents ?? state.currentBillCents, + savingsCents: savingsCents ?? state.savingsCents, + pendingInvoices: pendingInvoices ?? state.pendingInvoices, + invoiceHistory: invoiceHistory ?? state.invoiceHistory, + spendBreakdown: spendBreakdown ?? state.spendBreakdown, + bankAccounts: bankAccounts ?? state.bankAccounts, + ), + ); + }, + onError: (String errorKey) => state.copyWith( + status: BillingStatus.failure, + errorMessage: errorKey, ), ); } diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart index 542ebc28..b68fc7e6 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart @@ -56,7 +56,7 @@ class _ShiftCompletionReviewPageState extends State { final DateFormat formatter = DateFormat('EEEE, MMMM d'); final String dateLabel = resolvedInvoice.dueDate != null ? formatter.format(resolvedInvoice.dueDate!) - : 'N/A'; + : 'N/A'; // TODO: localize return Scaffold( appBar: UiAppBar( @@ -85,7 +85,7 @@ class _ShiftCompletionReviewPageState extends State { bottomNavigationBar: Container( padding: const EdgeInsets.all(UiConstants.space5), decoration: BoxDecoration( - color: Colors.white, + color: UiColors.primaryForeground, border: Border( top: BorderSide(color: UiColors.border.withValues(alpha: 0.5)), ), 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 index e4d41037..39f1619c 100644 --- 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 @@ -19,7 +19,7 @@ class BillingPageSkeleton extends StatelessWidget { padding: const EdgeInsets.all(UiConstants.space5), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ // Pending invoices section header const UiShimmerSectionHeader(), const SizedBox(height: UiConstants.space3), @@ -39,7 +39,7 @@ class BillingPageSkeleton extends StatelessWidget { ), child: const Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ UiShimmerLine(width: 160, height: 16), SizedBox(height: UiConstants.space4), // Breakdown rows 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 index 978b5f38..08a0c9af 100644 --- 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 @@ -10,7 +10,7 @@ class BreakdownRowSkeleton extends StatelessWidget { Widget build(BuildContext context) { return const Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + 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/invoice_card_skeleton.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/invoice_card_skeleton.dart index e86811db..d008d29a 100644 --- 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 @@ -16,10 +16,10 @@ class InvoiceCardSkeleton extends StatelessWidget { ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + children: [ UiShimmerBox( width: 72, height: 24, @@ -35,10 +35,10 @@ class InvoiceCardSkeleton extends StatelessWidget { const SizedBox(height: UiConstants.space4), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + children: [ const Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ UiShimmerLine(width: 80, height: 10), SizedBox(height: UiConstants.space1), UiShimmerLine(width: 100, height: 18), diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart index 475bd5bb..c1a65fc6 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart @@ -95,8 +95,8 @@ class CompletionReviewActions extends StatelessWidget { context: context, builder: (BuildContext dialogContext) => AlertDialog( title: Text(t.client_billing.flag_dialog.title), - surfaceTintColor: Colors.white, - backgroundColor: Colors.white, + surfaceTintColor: UiColors.primaryForeground, + backgroundColor: UiColors.primaryForeground, content: TextField( controller: controller, decoration: InputDecoration( diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_search_and_tabs.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_search_and_tabs.dart index eca816a3..89968e09 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_search_and_tabs.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_search_and_tabs.dart @@ -23,7 +23,7 @@ class CompletionReviewSearchAndTabs extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4), decoration: BoxDecoration( - color: const Color(0xFFF1F5F9), + color: UiColors.muted, borderRadius: UiConstants.radiusMd, ), child: TextField( @@ -69,17 +69,17 @@ class CompletionReviewSearchAndTabs extends StatelessWidget { child: Container( height: 40, decoration: BoxDecoration( - color: isSelected ? const Color(0xFF2563EB) : Colors.white, + color: isSelected ? UiColors.primary : UiColors.white, borderRadius: UiConstants.radiusMd, border: Border.all( - color: isSelected ? const Color(0xFF2563EB) : UiColors.border, + color: isSelected ? UiColors.primary : UiColors.border, ), ), child: Center( child: Text( text, style: UiTypography.body2b.copyWith( - color: isSelected ? Colors.white : UiColors.textSecondary, + color: isSelected ? UiColors.primaryForeground : UiColors.textSecondary, ), ), ), 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 index 42bc6543..f09d4cda 100644 --- 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 @@ -15,7 +15,7 @@ class InvoicesListSkeleton extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(UiConstants.space5), child: Column( - children: List.generate(4, (int index) { + children: List.generate(4, (int index) { return Padding( padding: const EdgeInsets.only(bottom: UiConstants.space4), child: Container( @@ -26,10 +26,10 @@ class InvoicesListSkeleton extends StatelessWidget { ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + children: [ UiShimmerBox( width: 64, height: 22, @@ -47,10 +47,10 @@ class InvoicesListSkeleton extends StatelessWidget { const SizedBox(height: UiConstants.space3), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + children: [ const Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ UiShimmerLine(width: 80, height: 10), SizedBox(height: UiConstants.space1), UiShimmerLine(width: 100, height: 20), diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart index 3b594017..4f457954 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart @@ -33,7 +33,7 @@ class PendingInvoicesSection extends StatelessWidget { width: 8, height: 8, decoration: const BoxDecoration( - color: Colors.orange, + color: UiColors.textWarning, shape: BoxShape.circle, ), ), @@ -101,7 +101,7 @@ class PendingInvoiceCard extends StatelessWidget { final DateFormat formatter = DateFormat('EEEE, MMMM d'); final String dateLabel = invoice.dueDate != null ? formatter.format(invoice.dueDate!) - : 'N/A'; + : 'N/A'; // TODO: localize final double amountDollars = invoice.amountCents / 100.0; return Container( 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 3d7e2db1..0e4d08f9 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 @@ -3,7 +3,7 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:client_coverage/src/data/repositories_impl/coverage_repository_impl.dart'; -import 'package:client_coverage/src/domain/repositories/coverage_repository.dart'; +import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart'; import 'package:client_coverage/src/domain/usecases/cancel_late_worker_usecase.dart'; import 'package:client_coverage/src/domain/usecases/get_coverage_stats_usecase.dart'; import 'package:client_coverage/src/domain/usecases/get_shifts_for_date_usecase.dart'; @@ -21,8 +21,8 @@ class CoverageModule extends Module { @override void binds(Injector i) { // Repositories - i.addLazySingleton( - () => CoverageRepositoryImpl(apiService: i.get()), + i.addLazySingleton( + () => CoverageRepositoryInterfaceImpl(apiService: i.get()), ); // Use Cases diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart index 2010cec5..a105d241 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart @@ -1,14 +1,14 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:client_coverage/src/domain/repositories/coverage_repository.dart'; +import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart'; -/// V2 API implementation of [CoverageRepository]. +/// V2 API implementation of [CoverageRepositoryInterface]. /// /// Uses [BaseApiService] with [ClientEndpoints] for all backend access. -class CoverageRepositoryImpl implements CoverageRepository { - /// Creates a [CoverageRepositoryImpl]. - CoverageRepositoryImpl({required BaseApiService apiService}) +class CoverageRepositoryInterfaceImpl implements CoverageRepositoryInterface { + /// Creates a [CoverageRepositoryInterfaceImpl]. + CoverageRepositoryInterfaceImpl({required BaseApiService apiService}) : _apiService = apiService; final BaseApiService _apiService; diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository_interface.dart similarity index 95% rename from apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository.dart rename to apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository_interface.dart index c82bd45a..dac6ecd4 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository_interface.dart @@ -4,7 +4,7 @@ import 'package:krow_domain/krow_domain.dart'; /// /// Defines the contract for accessing coverage data via the V2 REST API, /// acting as a boundary between the Domain and Data layers. -abstract interface class CoverageRepository { +abstract interface class CoverageRepositoryInterface { /// Fetches shifts with assigned workers for a specific [date]. Future> getShiftsForDate({required DateTime date}); diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/cancel_late_worker_usecase.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/cancel_late_worker_usecase.dart index 2cc4e509..51c984f6 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/cancel_late_worker_usecase.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/cancel_late_worker_usecase.dart @@ -1,17 +1,17 @@ import 'package:krow_core/core.dart'; import 'package:client_coverage/src/domain/arguments/cancel_late_worker_arguments.dart'; -import 'package:client_coverage/src/domain/repositories/coverage_repository.dart'; +import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart'; /// Use case for cancelling a late worker's assignment. /// -/// Delegates to [CoverageRepository] to cancel the assignment via V2 API. +/// Delegates to [CoverageRepositoryInterface] to cancel the assignment via V2 API. class CancelLateWorkerUseCase implements UseCase { /// Creates a [CancelLateWorkerUseCase]. CancelLateWorkerUseCase(this._repository); - final CoverageRepository _repository; + final CoverageRepositoryInterface _repository; @override Future call(CancelLateWorkerArguments arguments) { diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_coverage_stats_usecase.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_coverage_stats_usecase.dart index b26034aa..24b7f77e 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_coverage_stats_usecase.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_coverage_stats_usecase.dart @@ -2,17 +2,17 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:client_coverage/src/domain/arguments/get_coverage_stats_arguments.dart'; -import 'package:client_coverage/src/domain/repositories/coverage_repository.dart'; +import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart'; /// Use case for fetching aggregated coverage statistics for a specific date. /// -/// Delegates to [CoverageRepository] and returns a [CoverageStats] entity. +/// Delegates to [CoverageRepositoryInterface] and returns a [CoverageStats] entity. class GetCoverageStatsUseCase implements UseCase { /// Creates a [GetCoverageStatsUseCase]. GetCoverageStatsUseCase(this._repository); - final CoverageRepository _repository; + final CoverageRepositoryInterface _repository; @override Future call(GetCoverageStatsArguments arguments) { diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_shifts_for_date_usecase.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_shifts_for_date_usecase.dart index 7e021a18..67ef35df 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_shifts_for_date_usecase.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_shifts_for_date_usecase.dart @@ -2,17 +2,17 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:client_coverage/src/domain/arguments/get_shifts_for_date_arguments.dart'; -import 'package:client_coverage/src/domain/repositories/coverage_repository.dart'; +import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart'; /// Use case for fetching shifts with workers for a specific date. /// -/// Delegates to [CoverageRepository] and returns V2 [ShiftWithWorkers] entities. +/// Delegates to [CoverageRepositoryInterface] and returns V2 [ShiftWithWorkers] entities. class GetShiftsForDateUseCase implements UseCase> { /// Creates a [GetShiftsForDateUseCase]. GetShiftsForDateUseCase(this._repository); - final CoverageRepository _repository; + final CoverageRepositoryInterface _repository; @override Future> call(GetShiftsForDateArguments arguments) { diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/submit_worker_review_usecase.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/submit_worker_review_usecase.dart index be9a17d1..4e3d094d 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/submit_worker_review_usecase.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/submit_worker_review_usecase.dart @@ -1,17 +1,17 @@ import 'package:krow_core/core.dart'; import 'package:client_coverage/src/domain/arguments/submit_worker_review_arguments.dart'; -import 'package:client_coverage/src/domain/repositories/coverage_repository.dart'; +import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart'; /// Use case for submitting a worker review from the coverage page. /// -/// Validates the rating range and delegates to [CoverageRepository]. +/// Validates the rating range and delegates to [CoverageRepositoryInterface]. class SubmitWorkerReviewUseCase implements UseCase { /// Creates a [SubmitWorkerReviewUseCase]. SubmitWorkerReviewUseCase(this._repository); - final CoverageRepository _repository; + final CoverageRepositoryInterface _repository; @override Future call(SubmitWorkerReviewArguments arguments) async { 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 291234f6..61e79132 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 @@ -10,14 +10,14 @@ import 'package:client_coverage/src/presentation/blocs/coverage_event.dart'; import 'package:client_coverage/src/presentation/blocs/coverage_state.dart'; import 'package:client_coverage/src/presentation/widgets/coverage_calendar_selector.dart'; import 'package:client_coverage/src/presentation/widgets/coverage_page_skeleton.dart'; -import 'package:client_coverage/src/presentation/widgets/coverage_quick_stats.dart'; import 'package:client_coverage/src/presentation/widgets/coverage_shift_list.dart'; import 'package:client_coverage/src/presentation/widgets/coverage_stats_header.dart'; import 'package:client_coverage/src/presentation/widgets/late_workers_alert.dart'; /// Page for displaying daily coverage information. /// -/// Shows shifts, worker statuses, and coverage statistics for a selected date. +/// Shows shifts, worker statuses, and coverage statistics for a selected date +/// using a collapsible SliverAppBar with gradient header and live activity feed. class CoveragePage extends StatefulWidget { /// Creates a [CoveragePage]. const CoveragePage({super.key}); @@ -27,14 +27,13 @@ class CoveragePage extends StatefulWidget { } class _CoveragePageState extends State { + /// Controller for the [CustomScrollView]. late ScrollController _scrollController; - bool _isScrolled = false; @override void initState() { super.initState(); _scrollController = ScrollController(); - _scrollController.addListener(_onScroll); } @override @@ -43,16 +42,6 @@ class _CoveragePageState extends State { super.dispose(); } - void _onScroll() { - if (_scrollController.hasClients) { - if (_scrollController.offset > 180 && !_isScrolled) { - setState(() => _isScrolled = true); - } else if (_scrollController.offset <= 180 && _isScrolled) { - setState(() => _isScrolled = false); - } - } - } - @override Widget build(BuildContext context) { return BlocProvider( @@ -69,6 +58,21 @@ class _CoveragePageState extends State { type: UiSnackbarType.error, ); } + if (state.writeStatus == CoverageWriteStatus.submitted) { + UiSnackbar.show( + context, + message: context.t.client_coverage.review.success, + type: UiSnackbarType.success, + ); + } + if (state.writeStatus == CoverageWriteStatus.submitFailure && + state.writeErrorMessage != null) { + UiSnackbar.show( + context, + message: translateErrorKey(state.writeErrorMessage!), + type: UiSnackbarType.error, + ); + } }, builder: (BuildContext context, CoverageState state) { final DateTime selectedDate = state.selectedDate ?? DateTime.now(); @@ -78,19 +82,26 @@ class _CoveragePageState extends State { slivers: [ SliverAppBar( pinned: true, - expandedHeight: 300.0, + expandedHeight: 316.0, backgroundColor: UiColors.primary, - title: AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: Text( - _isScrolled - ? DateFormat('MMMM d').format(selectedDate) - : context.t.client_coverage.page.daily_coverage, - key: ValueKey(_isScrolled), - style: UiTypography.title2m.copyWith( - color: UiColors.primaryForeground, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + context.t.client_coverage.page.daily_coverage, + style: UiTypography.title2m.copyWith( + color: UiColors.primaryForeground, + ), ), - ), + Text( + DateFormat('EEEE, MMMM d').format(selectedDate), + style: UiTypography.body3r.copyWith( + color: UiColors.primaryForeground + .withValues(alpha: 0.6), + ), + ), + ], ), actions: [ IconButton( @@ -117,10 +128,13 @@ class _CoveragePageState extends State { ], flexibleSpace: Container( decoration: const BoxDecoration( + // Intentional gradient: the second stop is a darker + // variant of UiColors.primary used only for the + // coverage header visual effect. gradient: LinearGradient( colors: [ UiColors.primary, - UiColors.primary, + Color(0xFF0626A8), ], begin: Alignment.topLeft, end: Alignment.bottomRight, @@ -154,6 +168,12 @@ class _CoveragePageState extends State { state.stats?.totalPositionsConfirmed ?? 0, totalNeeded: state.stats?.totalPositionsNeeded ?? 0, + totalCheckedIn: + state.stats?.totalWorkersCheckedIn ?? 0, + totalEnRoute: + state.stats?.totalWorkersEnRoute ?? 0, + totalLate: + state.stats?.totalWorkersLate ?? 0, ), ], ), @@ -176,7 +196,10 @@ class _CoveragePageState extends State { ); } - /// Builds the main body content based on the current state. + /// Builds the main body content based on the current [CoverageState]. + /// + /// Displays a skeleton loader, error state, or the live activity feed + /// with late worker alerts and shift list. Widget _buildBody({ required BuildContext context, required CoverageState state, @@ -227,24 +250,19 @@ class _CoveragePageState extends State { crossAxisAlignment: CrossAxisAlignment.start, spacing: UiConstants.space6, children: [ - Column( - spacing: UiConstants.space2, - children: [ - if (state.stats != null && - state.stats!.totalWorkersLate > 0) ...[ - LateWorkersAlert( - lateCount: state.stats!.totalWorkersLate, - ), - ], - if (state.stats != null) ...[ - CoverageQuickStats(stats: state.stats!), - ], - ], - ), + if (state.stats != null && + state.stats!.totalWorkersLate > 0) ...[ + LateWorkersAlert( + lateCount: state.stats!.totalWorkersLate, + ), + ], Text( - '${context.t.client_coverage.page.shifts} (${state.shifts.length})', - style: UiTypography.title2b.copyWith( - color: UiColors.textPrimary, + context.t.client_coverage.page.live_activity, + style: UiTypography.body4m.copyWith( + color: UiColors.textSecondary, + letterSpacing: 2.0, + fontWeight: FontWeight.w900, + fontSize: 10, ), ), CoverageShiftList(shifts: state.shifts), diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/cancel_late_worker_sheet.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/cancel_late_worker_sheet.dart new file mode 100644 index 00000000..ba375262 --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/cancel_late_worker_sheet.dart @@ -0,0 +1,188 @@ +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:krow_domain/krow_domain.dart'; + +import 'package:client_coverage/src/presentation/blocs/coverage_bloc.dart'; +import 'package:client_coverage/src/presentation/blocs/coverage_event.dart'; + +/// Bottom sheet modal for cancelling a late worker's assignment. +/// +/// Collects an optional cancellation reason and dispatches a +/// [CoverageCancelLateWorkerRequested] event to the [CoverageBloc]. +class CancelLateWorkerSheet extends StatefulWidget { + /// Creates a [CancelLateWorkerSheet]. + const CancelLateWorkerSheet({ + required this.worker, + super.key, + }); + + /// The assigned worker to cancel. + final AssignedWorker worker; + + /// Shows the cancel-late-worker bottom sheet. + /// + /// Captures [CoverageBloc] from [context] before opening so the sheet + /// can dispatch events without relying on an ancestor that may be + /// deactivated. + static void show(BuildContext context, {required AssignedWorker worker}) { + final CoverageBloc bloc = ReadContext(context).read(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(UiConstants.space4), + ), + ), + builder: (_) => BlocProvider.value( + value: bloc, + child: CancelLateWorkerSheet(worker: worker), + ), + ); + } + + @override + State createState() => _CancelLateWorkerSheetState(); +} + +class _CancelLateWorkerSheetState extends State { + /// Controller for the optional cancellation reason text field. + final TextEditingController _reasonController = TextEditingController(); + + @override + void dispose() { + _reasonController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final TranslationsClientCoverageCancelEn l10n = + context.t.client_coverage.cancel; + + return Padding( + padding: EdgeInsets.only( + left: UiConstants.space4, + right: UiConstants.space4, + top: UiConstants.space3, + bottom: MediaQuery.of(context).viewInsets.bottom + UiConstants.space4, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: UiColors.border, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: UiConstants.space4), + + // Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + spacing: UiConstants.space3, + children: [ + const Icon( + UiIcons.warning, + color: UiColors.destructive, + size: 28, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.title, style: UiTypography.title1b.textError), + Text( + l10n.subtitle, + style: UiTypography.body2r.textSecondary, + ), + ], + ), + ], + ), + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: const Icon( + UiIcons.close, + color: UiColors.textSecondary, + size: 24, + ), + ), + ], + ), + const SizedBox(height: UiConstants.space4), + + // Body + Text( + l10n.confirm_message(name: widget.worker.fullName), + style: UiTypography.body1r, + ), + const SizedBox(height: UiConstants.space1), + Text( + l10n.helper_text, + style: UiTypography.body2r.textSecondary, + ), + const SizedBox(height: UiConstants.space4), + + // Reason field + UiTextField( + hintText: l10n.reason_placeholder, + maxLines: 2, + controller: _reasonController, + ), + const SizedBox(height: UiConstants.space4), + + // Action buttons + Row( + children: [ + Expanded( + child: UiButton.secondary( + text: l10n.keep_worker, + onPressed: () => Navigator.of(context).pop(), + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: UiButton.primary( + text: l10n.confirm, + onPressed: () => _onConfirm(context), + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.destructive, + foregroundColor: UiColors.primaryForeground, + ), + ), + ), + ], + ), + + const SizedBox(height: UiConstants.space24), + ], + ), + ); + } + + /// Dispatches the cancel event and closes the sheet. + void _onConfirm(BuildContext context) { + final String reason = _reasonController.text.trim(); + + ReadContext(context).read().add( + CoverageCancelLateWorkerRequested( + assignmentId: widget.worker.assignmentId, + reason: reason.isNotEmpty ? reason : null, + ), + ); + + Navigator.of(context).pop(); + } +} 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 8ae4ce85..44bc9670 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 @@ -110,7 +110,7 @@ class _CoverageCalendarSelectorState extends State { decoration: BoxDecoration( color: isSelected ? UiColors.primaryForeground - : UiColors.primaryForeground.withOpacity(0.1), + : UiColors.primaryForeground.withAlpha(25), borderRadius: UiConstants.radiusLg, border: isToday && !isSelected ? Border.all( @@ -122,6 +122,14 @@ class _CoverageCalendarSelectorState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ + Text( + DateFormat('E').format(date), + style: UiTypography.body4m.copyWith( + color: isSelected + ? UiColors.primary + : UiColors.primaryForeground.withAlpha(179), + ), + ), Text( date.day.toString().padLeft(2, '0'), style: UiTypography.body1b.copyWith( @@ -130,14 +138,6 @@ class _CoverageCalendarSelectorState extends State { : UiColors.primaryForeground, ), ), - Text( - DateFormat('E').format(date), - style: UiTypography.body4m.copyWith( - color: isSelected - ? UiColors.mutedForeground - : UiColors.primaryForeground.withOpacity(0.7), - ), - ), ], ), ), 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 index 448b7f60..6d85aec5 100644 --- 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 @@ -5,40 +5,30 @@ import 'package:client_coverage/src/presentation/widgets/coverage_page_skeleton/ /// 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. +/// Shows placeholder shapes for the live activity section label 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( + return const UiShimmer( child: Padding( - padding: const EdgeInsets.all(UiConstants.space5), + padding: 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), + children: [ + // "LIVE ACTIVITY" section label placeholder + UiShimmerLine(width: 100, height: 10), + 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(), + ShiftCardSkeleton(), + SizedBox(height: UiConstants.space3), + ShiftCardSkeleton(), + SizedBox(height: UiConstants.space3), + ShiftCardSkeleton(), ], ), ), 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 index c74212cd..1d890fb4 100644 --- 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 @@ -14,20 +14,20 @@ class ShiftCardSkeleton extends StatelessWidget { borderRadius: UiConstants.radiusLg, ), clipBehavior: Clip.antiAlias, - child: Column( - children: [ + child: Column( + children: [ // Shift header Padding( padding: const EdgeInsets.all(UiConstants.space4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + 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: [ + children: [ const UiShimmerLine(width: 80, height: 12), const Spacer(), UiShimmerBox( @@ -47,7 +47,7 @@ class ShiftCardSkeleton extends StatelessWidget { horizontal: UiConstants.space3, ).copyWith(bottom: UiConstants.space3), child: const Column( - children: [ + 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 deleted file mode 100644 index e1e9a85b..00000000 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart +++ /dev/null @@ -1,45 +0,0 @@ -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 'package:client_coverage/src/presentation/widgets/coverage_stat_card.dart'; - -/// Quick statistics cards showing coverage metrics. -/// -/// Displays checked-in and en-route worker counts. -class CoverageQuickStats extends StatelessWidget { - /// Creates a [CoverageQuickStats]. - const CoverageQuickStats({ - required this.stats, - super.key, - }); - - /// The coverage statistics to display. - final CoverageStats stats; - - @override - Widget build(BuildContext context) { - return Row( - spacing: UiConstants.space2, - children: [ - Expanded( - child: CoverageStatCard( - icon: UiIcons.success, - label: context.t.client_coverage.stats.checked_in, - value: stats.totalWorkersCheckedIn.toString(), - color: UiColors.iconSuccess, - ), - ), - Expanded( - child: CoverageStatCard( - icon: UiIcons.clock, - label: context.t.client_coverage.stats.en_route, - value: stats.totalWorkersEnRoute.toString(), - color: UiColors.textWarning, - ), - ), - ], - ); - } -} 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 10923545..8e284dc1 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,13 +4,17 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:client_coverage/src/presentation/widgets/cancel_late_worker_sheet.dart'; import 'package:client_coverage/src/presentation/widgets/shift_header.dart'; import 'package:client_coverage/src/presentation/widgets/worker_row.dart'; +import 'package:client_coverage/src/presentation/widgets/worker_review_sheet.dart'; -/// List of shifts with their workers. +/// Displays a list of shifts as collapsible cards with worker details. /// -/// Displays all shifts for the selected date, or an empty state if none exist. -class CoverageShiftList extends StatelessWidget { +/// Each shift is rendered as a card with a tappable [ShiftHeader] that toggles +/// visibility of the worker rows beneath it. All cards start expanded. +/// Shows an empty state when [shifts] is empty. +class CoverageShiftList extends StatefulWidget { /// Creates a [CoverageShiftList]. const CoverageShiftList({ required this.shifts, @@ -20,17 +24,73 @@ class CoverageShiftList extends StatelessWidget { /// The list of shifts to display. final List shifts; + @override + State createState() => _CoverageShiftListState(); +} + +/// State for [CoverageShiftList] managing which shift cards are expanded. +class _CoverageShiftListState extends State { + /// Set of shift IDs whose cards are currently expanded. + final Set _expandedShiftIds = {}; + + /// Whether the expanded set has been initialised from the first build. + bool _initialised = false; + /// Formats a [DateTime] to a readable time string (h:mm a). String _formatTime(DateTime? time) { if (time == null) return ''; return DateFormat('h:mm a').format(time); } + /// Toggles the expanded / collapsed state for the shift with [shiftId]. + void _toggleShift(String shiftId) { + setState(() { + if (_expandedShiftIds.contains(shiftId)) { + _expandedShiftIds.remove(shiftId); + } else { + _expandedShiftIds.add(shiftId); + } + }); + } + + /// Seeds [_expandedShiftIds] with all current shift IDs on first build, + /// and adds any new shift IDs when the widget is rebuilt with new data. + void _ensureInitialised() { + if (!_initialised) { + _expandedShiftIds.addAll( + widget.shifts.map((ShiftWithWorkers s) => s.shiftId), + ); + _initialised = true; + return; + } + // Add any new shift IDs that arrived after initial build. + for (final ShiftWithWorkers shift in widget.shifts) { + if (!_expandedShiftIds.contains(shift.shiftId)) { + _expandedShiftIds.add(shift.shiftId); + } + } + } + + @override + void didUpdateWidget(covariant CoverageShiftList oldWidget) { + super.didUpdateWidget(oldWidget); + // Add newly-appeared shift IDs so they start expanded. + for (final ShiftWithWorkers shift in widget.shifts) { + if (!oldWidget.shifts.any( + (ShiftWithWorkers old) => old.shiftId == shift.shiftId, + )) { + _expandedShiftIds.add(shift.shiftId); + } + } + } + @override Widget build(BuildContext context) { + _ensureInitialised(); + final TranslationsClientCoverageEn l10n = context.t.client_coverage; - if (shifts.isEmpty) { + if (widget.shifts.isEmpty) { return Container( padding: const EdgeInsets.all(UiConstants.space8), width: double.infinity, @@ -57,66 +117,137 @@ class CoverageShiftList extends StatelessWidget { } return Column( - children: shifts.map((ShiftWithWorkers shift) { + children: widget.shifts.map((ShiftWithWorkers shift) { final int coveragePercent = shift.requiredWorkerCount > 0 ? ((shift.assignedWorkerCount / shift.requiredWorkerCount) * 100) .round() : 0; + // Per-shift worker status counts. + final int onSite = shift.assignedWorkers + .where( + (AssignedWorker w) => w.status == AssignmentStatus.checkedIn, + ) + .length; + final int enRoute = shift.assignedWorkers + .where( + (AssignedWorker w) => + w.status == AssignmentStatus.accepted && w.checkInAt == null, + ) + .length; + final int lateCount = shift.assignedWorkers + .where( + (AssignedWorker w) => w.status == AssignmentStatus.noShow, + ) + .length; + + final bool isExpanded = _expandedShiftIds.contains(shift.shiftId); + return Container( margin: const EdgeInsets.only(bottom: UiConstants.space3), decoration: BoxDecoration( color: UiColors.bgPopup, - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radius2xl, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], ), clipBehavior: Clip.antiAlias, child: Column( children: [ ShiftHeader( title: shift.roleName, - location: '', // V2 API does not return location on coverage startTime: _formatTime(shift.timeRange.startsAt), current: shift.assignedWorkerCount, total: shift.requiredWorkerCount, coveragePercent: coveragePercent, shiftId: shift.shiftId, + onSiteCount: onSite, + enRouteCount: enRoute, + lateCount: lateCount, + isExpanded: isExpanded, + onToggle: () => _toggleShift(shift.shiftId), + ), + AnimatedCrossFade( + firstChild: const SizedBox.shrink(), + secondChild: _buildWorkerSection(shift, l10n), + crossFadeState: isExpanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 200), ), - if (shift.assignedWorkers.isNotEmpty) - Padding( - padding: const EdgeInsets.all(UiConstants.space3), - child: Column( - children: shift.assignedWorkers - .map((AssignedWorker worker) { - final bool isLast = - worker == shift.assignedWorkers.last; - return Padding( - padding: EdgeInsets.only( - bottom: isLast ? 0 : UiConstants.space2, - ), - child: WorkerRow( - worker: worker, - shiftStartTime: - _formatTime(shift.timeRange.startsAt), - ), - ); - }).toList(), - ), - ) - else - Padding( - padding: const EdgeInsets.all(UiConstants.space4), - child: Text( - l10n.no_workers_assigned, - style: UiTypography.body3r.copyWith( - color: UiColors.mutedForeground, - ), - ), - ), ], ), ); }).toList(), ); } + + /// Builds the expanded worker section for a shift including divider. + Widget _buildWorkerSection( + ShiftWithWorkers shift, + TranslationsClientCoverageEn l10n, + ) { + if (shift.assignedWorkers.isEmpty) { + return Column( + children: [ + const Divider(height: 1, color: UiColors.border), + Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Text( + l10n.no_workers_assigned, + style: UiTypography.body3r.copyWith( + color: UiColors.mutedForeground, + ), + ), + ), + ], + ); + } + + return Column( + children: [ + const Divider(height: 1, color: UiColors.border), + Padding( + padding: const EdgeInsets.all(UiConstants.space3), + child: Column( + children: + shift.assignedWorkers.map((AssignedWorker worker) { + final bool isLast = worker == shift.assignedWorkers.last; + return Padding( + padding: EdgeInsets.only( + bottom: isLast ? 0 : UiConstants.space2, + ), + child: WorkerRow( + worker: worker, + shiftStartTime: _formatTime(shift.timeRange.startsAt), + showRateButton: + worker.status == AssignmentStatus.checkedIn || + worker.status == AssignmentStatus.checkedOut || + worker.status == AssignmentStatus.completed, + showCancelButton: + DateTime.now().isAfter(shift.timeRange.startsAt) && + (worker.status == AssignmentStatus.noShow || + worker.status == AssignmentStatus.assigned || + worker.status == AssignmentStatus.accepted), + onRate: () => WorkerReviewSheet.show( + context, + worker: worker, + ), + onCancel: () => CancelLateWorkerSheet.show( + context, + worker: worker, + ), + ), + ); + }).toList(), + ), + ), + ], + ); + } } 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 deleted file mode 100644 index b82585ce..00000000 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_stat_card.dart +++ /dev/null @@ -1,64 +0,0 @@ -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 index 15b4b448..da92d7eb 100644 --- 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 @@ -2,72 +2,176 @@ 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. +/// Displays overall coverage statistics in the SliverAppBar expanded header. +/// +/// Shows the coverage percentage, a progress bar, and real-time worker +/// status counts (on site, en route, late) on a primary blue gradient +/// background with a semi-transparent white container. class CoverageStatsHeader extends StatelessWidget { - /// Creates a [CoverageStatsHeader]. + /// Creates a [CoverageStatsHeader] with coverage and worker status data. const CoverageStatsHeader({ required this.coveragePercent, required this.totalConfirmed, required this.totalNeeded, + required this.totalCheckedIn, + required this.totalEnRoute, + required this.totalLate, super.key, }); - /// The current coverage percentage. + /// The current overall coverage percentage (0-100). final double coveragePercent; /// The number of confirmed workers. final int totalConfirmed; - /// The total number of workers needed. + /// The total number of workers needed for full coverage. final int totalNeeded; + /// The number of workers currently checked in and on site. + final int totalCheckedIn; + + /// The number of workers currently en route. + final int totalEnRoute; + + /// The number of workers currently marked as late. + final int totalLate; + @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(UiConstants.space4), decoration: BoxDecoration( - color: UiColors.primaryForeground.withOpacity(0.1), - borderRadius: UiConstants.radiusLg, + color: UiColors.primaryForeground.withValues(alpha: 0.12), + borderRadius: UiConstants.radiusXl, ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Column( + Row( 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, - ), + Expanded( + child: _buildCoverageColumn(context), ), + _buildStatusColumn(context), ], ), + const SizedBox(height: UiConstants.space3), + _buildProgressBar(), ], ), ); } + + /// Builds the left column with the "Overall Coverage" label and percentage. + Widget _buildCoverageColumn(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_coverage.page.overall_coverage, + style: UiTypography.body3r.copyWith( + color: UiColors.primaryForeground.withValues(alpha: 0.6), + ), + ), + Text( + '${coveragePercent.toStringAsFixed(0)}%', + style: UiTypography.display1b.copyWith( + color: UiColors.primaryForeground, + ), + ), + ], + ); + } + + /// Builds the right column with on-site, en-route, and late stat items. + Widget _buildStatusColumn(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _buildStatRow( + context: context, + value: totalCheckedIn, + label: context.t.client_coverage.stats.on_site, + valueColor: UiColors.primaryForeground, + ), + const SizedBox(height: UiConstants.space1), + _buildStatRow( + context: context, + value: totalEnRoute, + label: context.t.client_coverage.stats.en_route, + valueColor: UiColors.accent, + ), + const SizedBox(height: UiConstants.space1), + _buildStatRow( + context: context, + value: totalLate, + label: context.t.client_coverage.stats.late, + valueColor: UiColors.tagError, + ), + ], + ); + } + + /// Builds a single stat row with a colored number and a muted label. + Widget _buildStatRow({ + required BuildContext context, + required int value, + required String label, + required Color valueColor, + }) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + value.toString(), + style: UiTypography.title2b.copyWith( + color: valueColor, + ), + ), + const SizedBox(width: UiConstants.space2), + Text( + label, + style: UiTypography.body4m.copyWith( + color: UiColors.primaryForeground.withValues(alpha: 0.6), + ), + ), + ], + ); + } + + /// Builds the horizontal progress bar indicating coverage fill. + Widget _buildProgressBar() { + final double clampedFraction = + (coveragePercent / 100).clamp(0.0, 1.0); + + return ClipRRect( + borderRadius: UiConstants.radiusFull, + child: SizedBox( + height: 8, + width: double.infinity, + child: Stack( + children: [ + Container( + decoration: BoxDecoration( + color: UiColors.primaryForeground.withValues(alpha: 0.2), + borderRadius: UiConstants.radiusFull, + ), + ), + FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: clampedFraction, + child: Container( + decoration: BoxDecoration( + color: UiColors.primaryForeground, + borderRadius: UiConstants.radiusFull, + ), + ), + ), + ], + ), + ), + ); + } } 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 716512cc..d6f1f400 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 @@ -2,38 +2,54 @@ 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. +/// Alert banner displayed when workers are running late. /// -/// Shows a warning banner when workers are running late. +/// Renders a solid red container with a warning icon, late worker count, +/// and auto-backup status message in white text. class LateWorkersAlert extends StatelessWidget { - /// Creates a [LateWorkersAlert]. + /// Creates a [LateWorkersAlert] with the given [lateCount]. const LateWorkersAlert({ required this.lateCount, super.key, }); - /// The number of late workers. + /// The number of workers currently marked as late. final int lateCount; @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.all(UiConstants.space3), + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space3, + ), decoration: BoxDecoration( - color: UiColors.destructive.withValues(alpha: 0.1), + color: UiColors.destructive, borderRadius: UiConstants.radiusLg, - border: Border.all( - color: UiColors.destructive, - width: 0.5, - ), + boxShadow: [ + BoxShadow( + color: UiColors.destructive.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], ), child: Row( - spacing: UiConstants.space4, children: [ - const Icon( - UiIcons.warning, - color: UiColors.destructive, + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + UiIcons.warning, + color: Colors.white, + size: 16, + ), ), + const SizedBox(width: UiConstants.space3), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -41,12 +57,14 @@ class LateWorkersAlert extends StatelessWidget { Text( context.t.client_coverage.alert .workers_running_late(n: lateCount, count: lateCount), - style: UiTypography.body1b.textError, + style: UiTypography.body1b.copyWith( + color: Colors.white, + ), ), Text( context.t.client_coverage.alert.auto_backup_searching, style: UiTypography.body3r.copyWith( - color: UiColors.textError.withValues(alpha: 0.7), + color: Colors.white.withValues(alpha: 0.8), ), ), ], 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 index ffa56b00..b0a81658 100644 --- 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 @@ -1,124 +1,198 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'package:client_coverage/src/presentation/widgets/coverage_badge.dart'; - -/// Header section for a shift card showing title, location, time, and coverage. +/// Tappable header for a collapsible shift card. +/// +/// Displays a status dot colour-coded by coverage, the shift title and time, +/// a filled/total badge, a linear progress bar, and per-shift worker summary +/// counts (on site, en route, late). Tapping anywhere triggers [onToggle]. 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, + required this.onSiteCount, + required this.enRouteCount, + required this.lateCount, + required this.isExpanded, + required this.onToggle, super.key, }); - /// The shift title. + /// The shift role or title. final String title; - /// The shift location. - final String location; - - /// The formatted shift start time. + /// Formatted shift start time (e.g. "8:00 AM"). final String startTime; /// Current number of assigned workers. final int current; - /// Total workers needed for the shift. + /// Total workers required for the shift. final int total; /// Coverage percentage (0-100+). final int coveragePercent; - /// The shift identifier. + /// Unique shift identifier. final String shiftId; + /// Number of workers currently on site (checked in). + final int onSiteCount; + + /// Number of workers en route (accepted but not checked in). + final int enRouteCount; + + /// Number of workers marked as late / no-show. + final int lateCount; + + /// Whether the shift card is currently expanded to show workers. + final bool isExpanded; + + /// Callback invoked when the header is tapped to expand or collapse. + final VoidCallback onToggle; + + /// Returns the status colour based on [coveragePercent]. + /// + /// Green for >= 100 %, yellow for >= 80 %, red otherwise. + Color _statusColor() { + if (coveragePercent >= 100) { + return UiColors.textSuccess; + } else if (coveragePercent >= 80) { + return UiColors.textWarning; + } + return UiColors.destructive; + } + @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, + final Color statusColor = _statusColor(); + final TranslationsClientCoverageStatsEn stats = + context.t.client_coverage.stats; + final double fillFraction = + total > 0 ? (current / total).clamp(0.0, 1.0) : 0.0; + + return InkWell( + onTap: onToggle, + child: Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Row 1: status dot, title + time, badge, chevron. + Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Row( - spacing: UiConstants.space2, - children: [ - Container( - width: UiConstants.space2, - height: UiConstants.space2, - decoration: const BoxDecoration( - color: UiColors.primary, - shape: BoxShape.circle, - ), + // Status dot. + Padding( + padding: const EdgeInsets.only(top: UiConstants.space1), + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: statusColor, + 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, - ), - ], - ), - ], + const SizedBox(width: UiConstants.space4), + // Title and start time. + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: UiTypography.body1b.textPrimary, + ), + const SizedBox(height: UiConstants.space1), + Row( + children: [ + const Icon( + UiIcons.clock, + size: 10, + color: UiColors.textSecondary, + ), + const SizedBox(width: 4), + Text( + startTime, + style: UiTypography.body3r.copyWith( + color: UiColors.textSecondary, + ), + ), + ], + ), + ], + ), + ), + // Coverage badge. + Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: UiConstants.space1, + ), + decoration: BoxDecoration( + color: statusColor.withAlpha(26), + borderRadius: UiConstants.radiusSm, + ), + child: Text( + '$current/$total', + style: UiTypography.body3b.copyWith(color: statusColor), + ), + ), + const SizedBox(width: UiConstants.space2), + // Expand / collapse chevron. + Icon( + isExpanded ? UiIcons.chevronUp : UiIcons.chevronDown, + size: 16, + color: UiColors.textSecondary, ), ], ), - ), - CoverageBadge( - current: current, - total: total, - coveragePercent: coveragePercent, - ), - ], + const SizedBox(height: UiConstants.space3), + // Progress bar. + ClipRRect( + borderRadius: UiConstants.radiusFull, + child: SizedBox( + height: 8, + width: double.infinity, + child: Stack( + children: [ + Container( + decoration: BoxDecoration( + color: UiColors.muted, + borderRadius: UiConstants.radiusFull, + ), + ), + FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: fillFraction, + child: Container( + decoration: BoxDecoration( + color: statusColor, + borderRadius: UiConstants.radiusFull, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: UiConstants.space2), + // Summary text: on site / en route / late. + Text( + '$onSiteCount ${stats.on_site} · ' + '$enRouteCount ${stats.en_route} · ' + '$lateCount ${stats.late}', + style: UiTypography.body3r.textSecondary, + ), + ], + ), ), ); } diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_review_sheet.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_review_sheet.dart new file mode 100644 index 00000000..f9246cd1 --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_review_sheet.dart @@ -0,0 +1,333 @@ +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:krow_domain/krow_domain.dart'; + +import 'package:client_coverage/src/presentation/blocs/coverage_bloc.dart'; +import 'package:client_coverage/src/presentation/blocs/coverage_event.dart'; +import 'package:client_coverage/src/presentation/blocs/coverage_state.dart'; + +/// Semantic color for the "favorite" toggle, representing a pink/heart accent. +/// No matching token in [UiColors] — kept as a local constant intentionally. +const Color _kFavoriteColor = Color(0xFFE91E63); + +/// Bottom sheet for submitting a worker review with rating, feedback, and flags. +class WorkerReviewSheet extends StatefulWidget { + const WorkerReviewSheet({required this.worker, super.key}); + + final AssignedWorker worker; + + static void show(BuildContext context, {required AssignedWorker worker}) { + final CoverageBloc bloc = ReadContext(context).read(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(UiConstants.space4), + ), + ), + builder: (_) => BlocProvider.value( + value: bloc, + child: WorkerReviewSheet(worker: worker), + ), + ); + } + + @override + State createState() => _WorkerReviewSheetState(); +} + +class _WorkerReviewSheetState extends State { + int _rating = 0; + bool _isFavorite = false; + bool _isBlocked = false; + final Set _selectedFlags = {}; + final TextEditingController _feedbackController = TextEditingController(); + + @override + void dispose() { + _feedbackController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final TranslationsClientCoverageReviewEn l10n = + context.t.client_coverage.review; + + final List ratingLabels = [ + l10n.rating_labels.poor, + l10n.rating_labels.fair, + l10n.rating_labels.good, + l10n.rating_labels.great, + l10n.rating_labels.excellent, + ]; + + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), + child: Padding( + padding: EdgeInsets.only( + left: UiConstants.space4, + right: UiConstants.space4, + top: UiConstants.space3, + bottom: MediaQuery.of(context).viewInsets.bottom + UiConstants.space4, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildDragHandle(), + const SizedBox(height: UiConstants.space4), + _buildHeader(context, l10n), + const SizedBox(height: UiConstants.space5), + Flexible( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildStarRating(ratingLabels), + const SizedBox(height: UiConstants.space5), + _buildToggles(l10n), + const SizedBox(height: UiConstants.space5), + _buildIssueFlags(l10n), + const SizedBox(height: UiConstants.space4), + UiTextField( + hintText: l10n.feedback_placeholder, + maxLines: 3, + controller: _feedbackController, + ), + ], + ), + ), + ), + const SizedBox(height: UiConstants.space4), + BlocBuilder( + buildWhen: (CoverageState previous, CoverageState current) => + previous.writeStatus != current.writeStatus, + builder: (BuildContext context, CoverageState state) { + return UiButton.primary( + text: l10n.submit, + fullWidth: true, + isLoading: + state.writeStatus == CoverageWriteStatus.submitting, + onPressed: _rating > 0 ? () => _onSubmit(context) : null, + ); + }, + ), + const SizedBox(height: UiConstants.space24), + ], + ), + ), + ); + } + + Widget _buildDragHandle() { + return Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: UiColors.textDisabled, + borderRadius: BorderRadius.circular(2), + ), + ), + ); + } + + Widget _buildHeader( + BuildContext context, + TranslationsClientCoverageReviewEn l10n, + ) { + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.worker.fullName, style: UiTypography.title1b), + Text(l10n.title, style: UiTypography.body2r.textSecondary), + ], + ), + ), + IconButton( + icon: const Icon(UiIcons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ); + } + + Widget _buildStarRating(List ratingLabels) { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(5, (int index) { + final bool isFilled = index < _rating; + return GestureDetector( + onTap: () => setState(() => _rating = index + 1), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space1, + ), + child: Icon( + UiIcons.star, + size: UiConstants.space8, + color: isFilled + ? UiColors.textWarning + : UiColors.textDisabled, + ), + ), + ); + }), + ), + if (_rating > 0) ...[ + const SizedBox(height: UiConstants.space2), + Text( + ratingLabels[_rating - 1], + style: UiTypography.body2r.textSecondary, + textAlign: TextAlign.center, + ), + ], + ], + ); + } + + Widget _buildToggles(TranslationsClientCoverageReviewEn l10n) { + return Row( + children: [ + Expanded( + child: _buildToggleButton( + icon: Icons.favorite, + label: l10n.favorite_label, + isActive: _isFavorite, + activeColor: _kFavoriteColor, + onTap: () => setState(() => _isFavorite = !_isFavorite), + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: _buildToggleButton( + icon: UiIcons.ban, + label: l10n.block_label, + isActive: _isBlocked, + activeColor: UiColors.destructive, + onTap: () => setState(() => _isBlocked = !_isBlocked), + ), + ), + ], + ); + } + + Widget _buildToggleButton({ + required IconData icon, + required String label, + required bool isActive, + required Color activeColor, + required VoidCallback onTap, + }) { + final Color bgColor = + isActive ? activeColor.withAlpha(26) : UiColors.muted; + final Color fgColor = + isActive ? activeColor : UiColors.textDisabled; + + return InkWell( + onTap: onTap, + borderRadius: UiConstants.radiusMd, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space3, + ), + decoration: BoxDecoration( + color: bgColor, + borderRadius: UiConstants.radiusMd, + border: isActive ? Border.all(color: activeColor, width: 0.5) : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: UiConstants.space5, color: fgColor), + const SizedBox(width: UiConstants.space2), + Text( + label, + style: UiTypography.body2r.copyWith(color: fgColor), + ), + ], + ), + ), + ); + } + + Widget _buildIssueFlags(TranslationsClientCoverageReviewEn l10n) { + final Map flagLabels = + { + ReviewIssueFlag.late: l10n.issue_flags.late, + ReviewIssueFlag.uniform: l10n.issue_flags.uniform, + ReviewIssueFlag.misconduct: l10n.issue_flags.misconduct, + ReviewIssueFlag.noShow: l10n.issue_flags.no_show, + ReviewIssueFlag.attitude: l10n.issue_flags.attitude, + ReviewIssueFlag.performance: l10n.issue_flags.performance, + ReviewIssueFlag.leftEarly: l10n.issue_flags.left_early, + }; + + return Wrap( + spacing: UiConstants.space2, + runSpacing: UiConstants.space2, + children: ReviewIssueFlag.values.map((ReviewIssueFlag flag) { + final bool isSelected = _selectedFlags.contains(flag); + final String label = flagLabels[flag] ?? flag.value; + + return FilterChip( + label: Text(label), + selected: isSelected, + onSelected: (bool selected) { + setState(() { + if (selected) { + _selectedFlags.add(flag); + } else { + _selectedFlags.remove(flag); + } + }); + }, + selectedColor: UiColors.primary, + labelStyle: isSelected + ? UiTypography.body3r.copyWith(color: UiColors.primaryForeground) + : UiTypography.body3r.textSecondary, + backgroundColor: UiColors.muted, + checkmarkColor: UiColors.primaryForeground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.space5), + ), + side: isSelected + ? const BorderSide(color: UiColors.primary) + : BorderSide.none, + ); + }).toList(), + ); + } + + void _onSubmit(BuildContext context) { + ReadContext(context).read().add( + CoverageSubmitReviewRequested( + staffId: widget.worker.staffId, + rating: _rating, + assignmentId: widget.worker.assignmentId, + feedback: _feedbackController.text.trim().isNotEmpty + ? _feedbackController.text.trim() + : null, + issueFlags: _selectedFlags.isNotEmpty + ? _selectedFlags + .map((ReviewIssueFlag f) => f.value) + .toList() + : null, + markAsFavorite: _isFavorite ? true : null, + ), + ); + Navigator.of(context).pop(); + } +} 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 index a2018238..f1e68021 100644 --- 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 @@ -10,6 +10,10 @@ class WorkerRow extends StatelessWidget { const WorkerRow({ required this.worker, required this.shiftStartTime, + this.showRateButton = false, + this.showCancelButton = false, + this.onRate, + this.onCancel, super.key, }); @@ -19,6 +23,18 @@ class WorkerRow extends StatelessWidget { /// The formatted shift start time. final String shiftStartTime; + /// Whether to show the rate action button. + final bool showRateButton; + + /// Whether to show the cancel action button. + final bool showCancelButton; + + /// Callback invoked when the rate button is tapped. + final VoidCallback? onRate; + + /// Callback invoked when the cancel button is tapped. + final VoidCallback? onCancel; + /// Formats a [DateTime] to a readable time string (h:mm a). String _formatCheckInTime(DateTime? time) { if (time == null) return ''; @@ -35,10 +51,6 @@ class WorkerRow extends StatelessWidget { Color textColor; IconData icon; String statusText; - Color badgeBg; - Color badgeText; - Color badgeBorder; - String badgeLabel; switch (worker.status) { case AssignmentStatus.checkedIn: @@ -50,10 +62,6 @@ class WorkerRow extends StatelessWidget { statusText = l10n.status_checked_in_at( time: _formatCheckInTime(worker.checkInAt), ); - badgeBg = UiColors.textSuccess.withAlpha(40); - badgeText = UiColors.textSuccess; - badgeBorder = badgeText; - badgeLabel = l10n.status_on_site; case AssignmentStatus.accepted: if (worker.checkInAt == null) { bg = UiColors.textWarning.withAlpha(26); @@ -62,10 +70,6 @@ class WorkerRow extends StatelessWidget { 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; @@ -73,10 +77,6 @@ class WorkerRow extends StatelessWidget { 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 AssignmentStatus.noShow: bg = UiColors.destructive.withAlpha(26); @@ -85,10 +85,6 @@ class WorkerRow extends StatelessWidget { 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 AssignmentStatus.checkedOut: bg = UiColors.muted.withAlpha(26); border = UiColors.border; @@ -96,10 +92,6 @@ class WorkerRow extends StatelessWidget { 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 AssignmentStatus.completed: bg = UiColors.iconSuccess.withAlpha(26); border = UiColors.iconSuccess; @@ -107,10 +99,6 @@ class WorkerRow extends StatelessWidget { 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 AssignmentStatus.assigned: case AssignmentStatus.swapRequested: case AssignmentStatus.cancelled: @@ -121,10 +109,6 @@ class WorkerRow extends StatelessWidget { textColor = UiColors.textSecondary; icon = UiIcons.clock; statusText = worker.status.value; - badgeBg = UiColors.textSecondary.withAlpha(40); - badgeText = UiColors.textSecondary; - badgeBorder = badgeText; - badgeLabel = worker.status.value; } return Container( @@ -197,23 +181,25 @@ class WorkerRow extends StatelessWidget { 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 (showRateButton && onRate != null) + GestureDetector( + onTap: onRate, + child: UiChip( + label: l10n.actions.rate, + size: UiChipSize.small, + leadingIcon: UiIcons.star, + ), + ), + if (showCancelButton && onCancel != null) + GestureDetector( + onTap: onCancel, + child: UiChip( + label: l10n.actions.cancel, + size: UiChipSize.small, + leadingIcon: UiIcons.close, + variant: UiChipVariant.destructive, ), ), - ), ], ), ], diff --git a/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_cubit.dart b/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_cubit.dart index 98a8b2a9..95a4d547 100644 --- a/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_cubit.dart +++ b/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_cubit.dart @@ -1,20 +1,34 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'client_main_state.dart'; +import 'package:client_main/src/presentation/blocs/client_main_state.dart'; -class ClientMainCubit extends Cubit implements Disposable { +/// Cubit that manages the client app's main navigation state. +/// +/// Tracks the active bottom bar tab and controls tab visibility +/// based on the current route. +class ClientMainCubit extends Cubit + with BlocErrorHandler + implements Disposable { + /// Creates a [ClientMainCubit] and starts listening for route changes. ClientMainCubit() : super(const ClientMainState()) { Modular.to.addListener(_onRouteChanged); _onRouteChanged(); } + /// Routes that should hide the bottom navigation bar. static const List _hideBottomBarPaths = [ ClientPaths.completionReview, ClientPaths.awaitingApproval, ]; + /// Updates state when the current route changes. + /// + /// Detects the active tab from the route path and determines + /// whether the bottom bar should be visible. void _onRouteChanged() { + if (isClosed) return; + final String path = Modular.to.path; int newIndex = state.currentIndex; @@ -41,6 +55,9 @@ class ClientMainCubit extends Cubit implements Disposable { } } + /// Navigates to the tab at [index] via Modular safe navigation. + /// + /// State update happens automatically via [_onRouteChanged]. void navigateToTab(int index) { if (index == state.currentIndex) return; @@ -61,7 +78,6 @@ class ClientMainCubit extends Cubit implements Disposable { Modular.to.toClientReports(); break; } - // State update will happen via _onRouteChanged } @override diff --git a/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_state.dart b/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_state.dart index 78650cae..669e316d 100644 --- a/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_state.dart +++ b/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_state.dart @@ -1,14 +1,20 @@ import 'package:equatable/equatable.dart'; +/// State for [ClientMainCubit] representing bottom navigation status. class ClientMainState extends Equatable { + /// Creates a [ClientMainState] with the given tab index and bar visibility. const ClientMainState({ this.currentIndex = 2, // Default to Home this.showBottomBar = true, }); + /// Index of the currently active bottom navigation tab. final int currentIndex; + + /// Whether the bottom navigation bar should be visible. final bool showBottomBar; + /// Creates a copy of this state with updated fields. ClientMainState copyWith({int? currentIndex, bool? showBottomBar}) { return ClientMainState( currentIndex: currentIndex ?? this.currentIndex, diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_mode_body.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_mode_body.dart index f58b780c..848774e7 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_mode_body.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_mode_body.dart @@ -1,25 +1,21 @@ -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - import 'package:client_home/src/presentation/blocs/client_home_bloc.dart'; import 'package:client_home/src/presentation/blocs/client_home_event.dart'; import 'package:client_home/src/presentation/blocs/client_home_state.dart'; import 'package:client_home/src/presentation/widgets/dashboard_widget_builder.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; /// Widget that displays the home dashboard in edit mode with drag-and-drop support. /// /// Allows users to reorder and rearrange dashboard widgets. class ClientHomeEditModeBody extends StatelessWidget { + /// Creates a [ClientHomeEditModeBody]. + const ClientHomeEditModeBody({required this.state, super.key}); + /// The current home state. final ClientHomeState state; - /// Creates a [ClientHomeEditModeBody]. - const ClientHomeEditModeBody({ - required this.state, - super.key, - }); - @override Widget build(BuildContext context) { return ReorderableListView( @@ -30,18 +26,15 @@ class ClientHomeEditModeBody extends StatelessWidget { 100, ), onReorder: (int oldIndex, int newIndex) { - BlocProvider.of(context) - .add(ClientHomeWidgetReordered(oldIndex, newIndex)); + BlocProvider.of( + context, + ).add(ClientHomeWidgetReordered(oldIndex, newIndex)); }, children: state.widgetOrder.map((String id) { return Container( key: ValueKey(id), margin: const EdgeInsets.only(bottom: UiConstants.space4), - child: DashboardWidgetBuilder( - id: id, - state: state, - isEditMode: true, - ), + child: DashboardWidgetBuilder(id: id, state: state, isEditMode: true), ); }).toList(), ); 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 index 2e186863..729999be 100644 --- 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 @@ -10,9 +10,9 @@ class ClientHomeHeaderSkeleton extends StatelessWidget { @override Widget build(BuildContext context) { - return UiShimmer( + return const UiShimmer( child: Padding( - padding: const EdgeInsets.fromLTRB( + padding: EdgeInsets.fromLTRB( UiConstants.space4, UiConstants.space4, UiConstants.space4, @@ -23,11 +23,11 @@ class ClientHomeHeaderSkeleton extends StatelessWidget { children: [ Row( children: [ - const UiShimmerCircle(size: UiConstants.space10), - const SizedBox(width: UiConstants.space3), + UiShimmerCircle(size: UiConstants.space10), + SizedBox(width: UiConstants.space3), Column( crossAxisAlignment: CrossAxisAlignment.start, - children: const [ + children: [ UiShimmerLine(width: 80, height: 12), SizedBox(height: UiConstants.space1), UiShimmerLine(width: 120, height: 16), @@ -37,7 +37,7 @@ class ClientHomeHeaderSkeleton extends StatelessWidget { ), Row( spacing: UiConstants.space2, - children: const [ + children: [ 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/reorder_section_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/reorder_section_skeleton.dart index 783fc2b0..0eec2161 100644 --- 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 @@ -10,9 +10,9 @@ class ReorderSectionSkeleton extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( + return const Column( crossAxisAlignment: CrossAxisAlignment.start, - children: const [ + children: [ UiShimmerSectionHeader(), SizedBox(height: UiConstants.space2), SizedBox( diff --git a/apps/mobile/packages/features/client/home/pubspec.yaml b/apps/mobile/packages/features/client/home/pubspec.yaml index c5043183..69d1116d 100644 --- a/apps/mobile/packages/features/client/home/pubspec.yaml +++ b/apps/mobile/packages/features/client/home/pubspec.yaml @@ -20,7 +20,8 @@ dependencies: path: ../../../design_system core_localization: path: ../../../core_localization - krow_domain: ^0.0.1 + krow_domain: + path: ../../../domain krow_core: path: ../../../core diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart index 83b669c6..f4a219ae 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.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:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:client_hubs/src/presentation/blocs/edit_hub/edit_hub_bloc.dart'; @@ -38,7 +39,7 @@ class EditHubPage extends StatelessWidget { message: message, type: UiSnackbarType.success, ); - Modular.to.pop(true); + Modular.to.popSafe(true); } if (state.status == EditHubStatus.failure && state.errorMessage != null) { @@ -65,7 +66,7 @@ class EditHubPage extends StatelessWidget { child: HubForm( hub: hub, costCenters: state.costCenters, - onCancel: () => Modular.to.pop(), + onCancel: () => Modular.to.popSafe(), onSave: ({ required String name, required String fullAddress, diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart index 63fa93f6..8404caeb 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart @@ -38,7 +38,7 @@ class HubDetailsPage extends StatelessWidget { message: message, type: UiSnackbarType.success, ); - Modular.to.pop(true); // Return true to indicate change + Modular.to.popSafe(true); // Return true to indicate change } if (state.status == HubDetailsStatus.failure && state.errorMessage != null) { @@ -117,7 +117,7 @@ class HubDetailsPage extends StatelessWidget { Future _navigateToEditPage(BuildContext context) async { final bool? saved = await Modular.to.toEditHub(hub: hub); if (saved == true && context.mounted) { - Modular.to.pop(true); // Return true to indicate change + Modular.to.popSafe(true); // Return true to indicate change } } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form.dart index eafeef01..5e141b0c 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form.dart @@ -112,7 +112,7 @@ class _HubFormState extends State { vertical: 16, ), decoration: BoxDecoration( - color: const Color(0xFFF8FAFD), + color: UiColors.muted, borderRadius: BorderRadius.circular( UiConstants.radiusBase * 1.5, ), @@ -225,7 +225,7 @@ class _HubFormState extends State { color: UiColors.textSecondary.withValues(alpha: 0.5), ), filled: true, - fillColor: const Color(0xFFF8FAFD), + fillColor: UiColors.muted, contentPadding: const EdgeInsets.symmetric( horizontal: UiConstants.space4, vertical: 16, 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 index 4fcb39bd..c420987a 100644 --- 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 @@ -13,7 +13,7 @@ class HubsPageSkeleton extends StatelessWidget { Widget build(BuildContext context) { return UiShimmer( child: Column( - children: List.generate(5, (int index) { + children: List.generate(5, (int index) { return Padding( padding: const EdgeInsets.only(bottom: UiConstants.space3), child: Container( @@ -23,7 +23,7 @@ class HubsPageSkeleton extends StatelessWidget { ), padding: const EdgeInsets.all(UiConstants.space4), child: Row( - children: [ + children: [ // Leading icon placeholder UiShimmerBox( width: 52, @@ -35,7 +35,7 @@ class HubsPageSkeleton extends StatelessWidget { const Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ UiShimmerLine(width: 160, height: 16), SizedBox(height: UiConstants.space2), UiShimmerLine(width: 200, height: 12), 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 7a3203c2..95eaf507 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 @@ -11,7 +11,11 @@ import 'domain/usecases/create_one_time_order_usecase.dart'; import 'domain/usecases/create_permanent_order_usecase.dart'; import 'domain/usecases/create_rapid_order_usecase.dart'; import 'domain/usecases/create_recurring_order_usecase.dart'; +import 'domain/usecases/get_hubs_usecase.dart'; +import 'domain/usecases/get_managers_by_hub_usecase.dart'; import 'domain/usecases/get_order_details_for_reorder_usecase.dart'; +import 'domain/usecases/get_roles_by_vendor_usecase.dart'; +import 'domain/usecases/get_vendors_usecase.dart'; import 'domain/usecases/parse_rapid_order_usecase.dart'; import 'domain/usecases/transcribe_rapid_order_usecase.dart'; import 'presentation/blocs/index.dart'; @@ -46,7 +50,7 @@ class ClientCreateOrderModule extends Module { ), ); - // UseCases + // Command UseCases (order creation) i.addLazySingleton(CreateOneTimeOrderUseCase.new); i.addLazySingleton(CreatePermanentOrderUseCase.new); i.addLazySingleton(CreateRecurringOrderUseCase.new); @@ -55,6 +59,12 @@ class ClientCreateOrderModule extends Module { i.addLazySingleton(ParseRapidOrderTextToOrderUseCase.new); i.addLazySingleton(GetOrderDetailsForReorderUseCase.new); + // Query UseCases (reference data loading) + i.addLazySingleton(GetVendorsUseCase.new); + i.addLazySingleton(GetRolesByVendorUseCase.new); + i.addLazySingleton(GetHubsUseCase.new); + i.addLazySingleton(GetManagersByHubUseCase.new); + // BLoCs i.add( () => RapidOrderBloc( @@ -63,15 +73,36 @@ class ClientCreateOrderModule extends Module { i.get(), ), ); - i.add(OneTimeOrderBloc.new); + i.add( + () => OneTimeOrderBloc( + i.get(), + i.get(), + i.get(), + i.get(), + i.get(), + i.get(), + ), + ); i.add( () => PermanentOrderBloc( i.get(), i.get(), - i.get(), + i.get(), + i.get(), + i.get(), + i.get(), + ), + ); + i.add( + () => RecurringOrderBloc( + i.get(), + i.get(), + i.get(), + i.get(), + i.get(), + i.get(), ), ); - i.add(RecurringOrderBloc.new); } @override diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart index 0093a45e..890fbeaf 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart @@ -1,15 +1,69 @@ import 'package:krow_core/core.dart'; -/// Arguments for the [CreateOneTimeOrderUseCase]. -/// -/// Wraps the V2 API payload map for a one-time order. -class OneTimeOrderArguments extends UseCaseArgument { - /// Creates a [OneTimeOrderArguments] with the given [payload]. - const OneTimeOrderArguments({required this.payload}); +/// A single position entry for a one-time order submission. +class OneTimeOrderPositionArgument extends UseCaseArgument { + /// Creates a [OneTimeOrderPositionArgument]. + const OneTimeOrderPositionArgument({ + required this.roleId, + required this.workerCount, + required this.startTime, + required this.endTime, + this.roleName, + this.lunchBreak, + }); - /// The V2 API payload map. - final Map payload; + /// The role ID for this position. + final String roleId; + + /// Human-readable role name, if available. + final String? roleName; + + /// Number of workers needed for this position. + final int workerCount; + + /// Shift start time in HH:mm format. + final String startTime; + + /// Shift end time in HH:mm format. + final String endTime; + + /// Break duration label (e.g. `'MIN_30'`, `'NO_BREAK'`), if set. + final String? lunchBreak; @override - List get props => [payload]; + List get props => + [roleId, roleName, workerCount, startTime, endTime, lunchBreak]; +} + +/// Typed arguments for [CreateOneTimeOrderUseCase]. +/// +/// Carries structured form data so the use case can build the V2 API payload. +class OneTimeOrderArguments extends UseCaseArgument { + /// Creates a [OneTimeOrderArguments] with the given structured fields. + const OneTimeOrderArguments({ + required this.hubId, + required this.eventName, + required this.orderDate, + required this.positions, + this.vendorId, + }); + + /// The selected hub ID. + final String hubId; + + /// The order event name / title. + final String eventName; + + /// The order date. + final DateTime orderDate; + + /// The list of position entries. + final List positions; + + /// The selected vendor ID, if applicable. + final String? vendorId; + + @override + List get props => + [hubId, eventName, orderDate, positions, vendorId]; } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart index e552278f..fb19864e 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart @@ -1,10 +1,75 @@ -/// Arguments for the [CreatePermanentOrderUseCase]. -/// -/// Wraps the V2 API payload map for a permanent order. -class PermanentOrderArguments { - /// Creates a [PermanentOrderArguments] with the given [payload]. - const PermanentOrderArguments({required this.payload}); +import 'package:krow_core/core.dart'; - /// The V2 API payload map. - final Map payload; +/// A single position entry for a permanent order submission. +class PermanentOrderPositionArgument extends UseCaseArgument { + /// Creates a [PermanentOrderPositionArgument]. + const PermanentOrderPositionArgument({ + required this.roleId, + required this.workerCount, + required this.startTime, + required this.endTime, + this.roleName, + }); + + /// The role ID for this position. + final String roleId; + + /// Human-readable role name, if available. + final String? roleName; + + /// Number of workers needed for this position. + final int workerCount; + + /// Shift start time in HH:mm format. + final String startTime; + + /// Shift end time in HH:mm format. + final String endTime; + + @override + List get props => + [roleId, roleName, workerCount, startTime, endTime]; +} + +/// Typed arguments for [CreatePermanentOrderUseCase]. +/// +/// Carries structured form data so the use case can build the V2 API payload. +class PermanentOrderArguments extends UseCaseArgument { + /// Creates a [PermanentOrderArguments] with the given structured fields. + const PermanentOrderArguments({ + required this.hubId, + required this.eventName, + required this.startDate, + required this.daysOfWeek, + required this.positions, + this.vendorId, + }); + + /// The selected hub ID. + final String hubId; + + /// The order event name / title. + final String eventName; + + /// The start date of the permanent order. + final DateTime startDate; + + /// Day-of-week labels (e.g. `['MON', 'WED', 'FRI']`). + final List daysOfWeek; + + /// The list of position entries. + final List positions; + + /// The selected vendor ID, if applicable. + final String? vendorId; + + @override + List get props => [ + hubId, + eventName, + startDate, + daysOfWeek, + positions, + vendorId, + ]; } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart index 25e8df02..01999078 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart @@ -1,10 +1,80 @@ -/// Arguments for the [CreateRecurringOrderUseCase]. -/// -/// Wraps the V2 API payload map for a recurring order. -class RecurringOrderArguments { - /// Creates a [RecurringOrderArguments] with the given [payload]. - const RecurringOrderArguments({required this.payload}); +import 'package:krow_core/core.dart'; - /// The V2 API payload map. - final Map payload; +/// A single position entry for a recurring order submission. +class RecurringOrderPositionArgument extends UseCaseArgument { + /// Creates a [RecurringOrderPositionArgument]. + const RecurringOrderPositionArgument({ + required this.roleId, + required this.workerCount, + required this.startTime, + required this.endTime, + this.roleName, + }); + + /// The role ID for this position. + final String roleId; + + /// Human-readable role name, if available. + final String? roleName; + + /// Number of workers needed for this position. + final int workerCount; + + /// Shift start time in HH:mm format. + final String startTime; + + /// Shift end time in HH:mm format. + final String endTime; + + @override + List get props => + [roleId, roleName, workerCount, startTime, endTime]; +} + +/// Typed arguments for [CreateRecurringOrderUseCase]. +/// +/// Carries structured form data so the use case can build the V2 API payload. +class RecurringOrderArguments extends UseCaseArgument { + /// Creates a [RecurringOrderArguments] with the given structured fields. + const RecurringOrderArguments({ + required this.hubId, + required this.eventName, + required this.startDate, + required this.endDate, + required this.recurringDays, + required this.positions, + this.vendorId, + }); + + /// The selected hub ID. + final String hubId; + + /// The order event name / title. + final String eventName; + + /// The start date of the recurring order period. + final DateTime startDate; + + /// The end date of the recurring order period. + final DateTime endDate; + + /// Day-of-week labels (e.g. `['MON', 'WED', 'FRI']`). + final List recurringDays; + + /// The list of position entries. + final List positions; + + /// The selected vendor ID, if applicable. + final String? vendorId; + + @override + List get props => [ + hubId, + eventName, + startDate, + endDate, + recurringDays, + positions, + vendorId, + ]; } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart index 948f0c2c..f74c4b63 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart @@ -5,16 +5,45 @@ import '../repositories/client_create_order_repository_interface.dart'; /// Use case for creating a one-time staffing order. /// -/// Delegates the V2 API payload to the repository. +/// Builds the V2 API payload from typed [OneTimeOrderArguments] and +/// delegates submission to the repository. Payload construction (date +/// formatting, position mapping, break-minutes conversion) is business +/// logic that belongs here, not in the BLoC. class CreateOneTimeOrderUseCase implements UseCase { /// Creates a [CreateOneTimeOrderUseCase]. const CreateOneTimeOrderUseCase(this._repository); + /// The create-order repository. final ClientCreateOrderRepositoryInterface _repository; @override Future call(OneTimeOrderArguments input) { - return _repository.createOneTimeOrder(input.payload); + final String orderDate = formatDateToIso(input.orderDate); + + final List> positions = + input.positions.map((OneTimeOrderPositionArgument p) { + return { + if (p.roleName != null) 'roleName': p.roleName, + if (p.roleId.isNotEmpty) 'roleId': p.roleId, + 'workerCount': p.workerCount, + 'startTime': p.startTime, + 'endTime': p.endTime, + if (p.lunchBreak != null && + p.lunchBreak != 'NO_BREAK' && + p.lunchBreak!.isNotEmpty) + 'lunchBreakMinutes': breakMinutesFromLabel(p.lunchBreak!), + }; + }).toList(); + + final Map payload = { + 'hubId': input.hubId, + 'eventName': input.eventName, + 'orderDate': orderDate, + 'positions': positions, + if (input.vendorId != null) 'vendorId': input.vendorId, + }; + + return _repository.createOneTimeOrder(payload); } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart index 0734f1ba..e33163d9 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart @@ -1,17 +1,61 @@ +import 'package:krow_core/core.dart'; + import '../arguments/permanent_order_arguments.dart'; import '../repositories/client_create_order_repository_interface.dart'; +/// Day-of-week labels in Sunday-first order, matching the V2 API convention. +const List _dayLabels = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', +]; + /// Use case for creating a permanent staffing order. /// -/// Delegates the V2 API payload to the repository. -class CreatePermanentOrderUseCase { +/// Builds the V2 API payload from typed [PermanentOrderArguments] and +/// delegates submission to the repository. Payload construction (date +/// formatting, day-of-week mapping, position mapping) is business +/// logic that belongs here, not in the BLoC. +class CreatePermanentOrderUseCase + implements UseCase { /// Creates a [CreatePermanentOrderUseCase]. const CreatePermanentOrderUseCase(this._repository); + /// The create-order repository. final ClientCreateOrderRepositoryInterface _repository; - /// Executes the use case with the given [args]. - Future call(PermanentOrderArguments args) { - return _repository.createPermanentOrder(args.payload); + @override + Future call(PermanentOrderArguments input) { + final String startDate = formatDateToIso(input.startDate); + + final List daysOfWeek = input.daysOfWeek + .map((String day) => _dayLabels.indexOf(day) % 7) + .toList(); + + final List> positions = + input.positions.map((PermanentOrderPositionArgument p) { + return { + if (p.roleName != null) 'roleName': p.roleName, + if (p.roleId.isNotEmpty) 'roleId': p.roleId, + 'workerCount': p.workerCount, + 'startTime': p.startTime, + 'endTime': p.endTime, + }; + }).toList(); + + final Map payload = { + 'hubId': input.hubId, + 'eventName': input.eventName, + 'startDate': startDate, + 'daysOfWeek': daysOfWeek, + 'positions': positions, + if (input.vendorId != null) 'vendorId': input.vendorId, + }; + + return _repository.createPermanentOrder(payload); } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart index 69462073..7bd1232f 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart @@ -1,17 +1,63 @@ +import 'package:krow_core/core.dart'; + import '../arguments/recurring_order_arguments.dart'; import '../repositories/client_create_order_repository_interface.dart'; +/// Day-of-week labels in Sunday-first order, matching the V2 API convention. +const List _dayLabels = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', +]; + /// Use case for creating a recurring staffing order. /// -/// Delegates the V2 API payload to the repository. -class CreateRecurringOrderUseCase { +/// Builds the V2 API payload from typed [RecurringOrderArguments] and +/// delegates submission to the repository. Payload construction (date +/// formatting, recurrence-day mapping, position mapping) is business +/// logic that belongs here, not in the BLoC. +class CreateRecurringOrderUseCase + implements UseCase { /// Creates a [CreateRecurringOrderUseCase]. const CreateRecurringOrderUseCase(this._repository); + /// The create-order repository. final ClientCreateOrderRepositoryInterface _repository; - /// Executes the use case with the given [args]. - Future call(RecurringOrderArguments args) { - return _repository.createRecurringOrder(args.payload); + @override + Future call(RecurringOrderArguments input) { + final String startDate = formatDateToIso(input.startDate); + final String endDate = formatDateToIso(input.endDate); + + final List recurrenceDays = input.recurringDays + .map((String day) => _dayLabels.indexOf(day) % 7) + .toList(); + + final List> positions = + input.positions.map((RecurringOrderPositionArgument p) { + return { + if (p.roleName != null) 'roleName': p.roleName, + if (p.roleId.isNotEmpty) 'roleId': p.roleId, + 'workerCount': p.workerCount, + 'startTime': p.startTime, + 'endTime': p.endTime, + }; + }).toList(); + + final Map payload = { + 'hubId': input.hubId, + 'eventName': input.eventName, + 'startDate': startDate, + 'endDate': endDate, + 'recurrenceDays': recurrenceDays, + 'positions': positions, + if (input.vendorId != null) 'vendorId': input.vendorId, + }; + + return _repository.createRecurringOrder(payload); } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_hubs_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_hubs_usecase.dart new file mode 100644 index 00000000..c5fc378e --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_hubs_usecase.dart @@ -0,0 +1,20 @@ +import 'package:krow_core/core.dart'; + +import '../models/order_hub.dart'; +import '../repositories/client_order_query_repository_interface.dart'; + +/// Use case for fetching team hubs for the current business. +/// +/// Returns the list of [OrderHub] instances available for order assignment. +class GetHubsUseCase implements NoInputUseCase> { + /// Creates a [GetHubsUseCase]. + const GetHubsUseCase(this._repository); + + /// The query repository for order reference data. + final ClientOrderQueryRepositoryInterface _repository; + + @override + Future> call() { + return _repository.getHubs(); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_managers_by_hub_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_managers_by_hub_usecase.dart new file mode 100644 index 00000000..d8f42de1 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_managers_by_hub_usecase.dart @@ -0,0 +1,21 @@ +import 'package:krow_core/core.dart'; + +import '../models/order_manager.dart'; +import '../repositories/client_order_query_repository_interface.dart'; + +/// Use case for fetching managers assigned to a specific hub. +/// +/// Takes a hub ID and returns the list of [OrderManager] instances +/// for that hub. +class GetManagersByHubUseCase implements UseCase> { + /// Creates a [GetManagersByHubUseCase]. + const GetManagersByHubUseCase(this._repository); + + /// The query repository for order reference data. + final ClientOrderQueryRepositoryInterface _repository; + + @override + Future> call(String hubId) { + return _repository.getManagersByHub(hubId); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_roles_by_vendor_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_roles_by_vendor_usecase.dart new file mode 100644 index 00000000..1d99bb92 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_roles_by_vendor_usecase.dart @@ -0,0 +1,21 @@ +import 'package:krow_core/core.dart'; + +import '../models/order_role.dart'; +import '../repositories/client_order_query_repository_interface.dart'; + +/// Use case for fetching roles offered by a specific vendor. +/// +/// Takes a vendor ID and returns the list of [OrderRole] instances +/// available from that vendor. +class GetRolesByVendorUseCase implements UseCase> { + /// Creates a [GetRolesByVendorUseCase]. + const GetRolesByVendorUseCase(this._repository); + + /// The query repository for order reference data. + final ClientOrderQueryRepositoryInterface _repository; + + @override + Future> call(String vendorId) { + return _repository.getRolesByVendor(vendorId); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_vendors_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_vendors_usecase.dart new file mode 100644 index 00000000..72c91f4d --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_vendors_usecase.dart @@ -0,0 +1,21 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../repositories/client_order_query_repository_interface.dart'; + +/// Use case for fetching the list of available vendors. +/// +/// Wraps the query repository to enforce the use-case boundary between +/// presentation and data layers. +class GetVendorsUseCase implements NoInputUseCase> { + /// Creates a [GetVendorsUseCase]. + const GetVendorsUseCase(this._repository); + + /// The query repository for order reference data. + final ClientOrderQueryRepositoryInterface _repository; + + @override + Future> call() { + return _repository.getVendors(); + } +} 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 e6efa3af..e40aa20f 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 @@ -2,9 +2,12 @@ import 'package:client_create_order/src/domain/arguments/one_time_order_argument 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_hubs_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_managers_by_hub_usecase.dart'; import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_roles_by_vendor_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_vendors_usecase.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -14,16 +17,20 @@ import 'one_time_order_state.dart'; /// BLoC for managing the multi-step one-time order creation form. /// -/// Builds V2 API payloads and uses [OrderPreview] for reorder. +/// Delegates all data fetching to query use cases and order submission +/// to [CreateOneTimeOrderUseCase]. Uses [OrderPreview] for reorder. class OneTimeOrderBloc extends Bloc with BlocErrorHandler, SafeBloc { - /// Creates the BLoC with required dependencies. + /// Creates the BLoC with required use case dependencies. OneTimeOrderBloc( this._createOneTimeOrderUseCase, this._getOrderDetailsForReorderUseCase, - this._queryRepository, + this._getVendorsUseCase, + this._getRolesByVendorUseCase, + this._getHubsUseCase, + this._getManagersByHubUseCase, ) : super(OneTimeOrderState.initial()) { on(_onVendorsLoaded); on(_onVendorChanged); @@ -45,16 +52,21 @@ class OneTimeOrderBloc extends Bloc final CreateOneTimeOrderUseCase _createOneTimeOrderUseCase; final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase; - final ClientOrderQueryRepositoryInterface _queryRepository; + final GetVendorsUseCase _getVendorsUseCase; + final GetRolesByVendorUseCase _getRolesByVendorUseCase; + final GetHubsUseCase _getHubsUseCase; + final GetManagersByHubUseCase _getManagersByHubUseCase; + /// Loads available vendors via the use case. Future _loadVendors() async { final List? vendors = await handleErrorWithResult( - action: () => _queryRepository.getVendors(), + action: () => _getVendorsUseCase(), onError: (_) => add(const OneTimeOrderVendorsLoaded([])), ); if (vendors != null) add(OneTimeOrderVendorsLoaded(vendors)); } + /// Loads roles for [vendorId] and maps them to presentation option models. Future _loadRolesForVendor( String vendorId, Emitter emit, @@ -62,7 +74,7 @@ class OneTimeOrderBloc extends Bloc final List? roles = await handleErrorWithResult( action: () async { final List result = - await _queryRepository.getRolesByVendor(vendorId); + await _getRolesByVendorUseCase(vendorId); return result .map((OrderRole r) => OneTimeOrderRoleOption( id: r.id, name: r.name, costPerHour: r.costPerHour)) @@ -74,10 +86,11 @@ class OneTimeOrderBloc extends Bloc if (roles != null) emit(state.copyWith(roles: roles)); } + /// Loads hubs via the use case and maps to presentation option models. Future _loadHubs() async { final List? hubs = await handleErrorWithResult( action: () async { - final List result = await _queryRepository.getHubs(); + final List result = await _getHubsUseCase(); return result .map((OrderHub h) => OneTimeOrderHubOption( id: h.id, @@ -100,12 +113,13 @@ class OneTimeOrderBloc extends Bloc if (hubs != null) add(OneTimeOrderHubsLoaded(hubs)); } + /// Loads managers for [hubId] via the use case. Future _loadManagersForHub(String hubId) async { final List? managers = await handleErrorWithResult( action: () async { final List result = - await _queryRepository.getManagersByHub(hubId); + await _getManagersByHubUseCase(hubId); return result .map((OrderManager m) => OneTimeOrderManagerOption(id: m.id, name: m.name)) @@ -224,7 +238,7 @@ class OneTimeOrderBloc extends Bloc emit(state.copyWith(positions: newPositions)); } - /// Builds a V2 API payload and submits the one-time order. + /// Builds typed arguments from form state and submits via the use case. Future _onSubmitted( OneTimeOrderSubmitted event, Emitter emit, @@ -236,12 +250,7 @@ class OneTimeOrderBloc extends Bloc final OneTimeOrderHubOption? selectedHub = state.selectedHub; if (selectedHub == null) throw const OrderMissingHubException(); - final String orderDate = - '${state.date.year.toString().padLeft(4, '0')}-' - '${state.date.month.toString().padLeft(2, '0')}-' - '${state.date.day.toString().padLeft(2, '0')}'; - - final List> positions = + final List positionArgs = state.positions.map((OneTimeOrderPosition p) { final OneTimeOrderRoleOption? role = state.roles .cast() @@ -249,28 +258,24 @@ class OneTimeOrderBloc extends Bloc (OneTimeOrderRoleOption? r) => r != null && r.id == p.role, orElse: () => null, ); - return { - if (role != null) 'roleName': role.name, - if (p.role.isNotEmpty) 'roleId': p.role, - 'workerCount': p.count, - 'startTime': p.startTime, - 'endTime': p.endTime, - if (p.lunchBreak != 'NO_BREAK' && p.lunchBreak.isNotEmpty) - 'lunchBreakMinutes': _breakMinutes(p.lunchBreak), - }; + return OneTimeOrderPositionArgument( + roleId: p.role, + roleName: role?.name, + workerCount: p.count, + startTime: p.startTime, + endTime: p.endTime, + lunchBreak: p.lunchBreak, + ); }).toList(); - final Map payload = { - 'hubId': selectedHub.id, - 'eventName': state.eventName, - 'orderDate': orderDate, - 'positions': positions, - if (state.selectedVendor != null) - 'vendorId': state.selectedVendor!.id, - }; - await _createOneTimeOrderUseCase( - OneTimeOrderArguments(payload: payload), + OneTimeOrderArguments( + hubId: selectedHub.id, + eventName: state.eventName, + orderDate: state.date, + positions: positionArgs, + vendorId: state.selectedVendor?.id, + ), ); emit(state.copyWith(status: OneTimeOrderStatus.success)); }, @@ -339,8 +344,8 @@ class OneTimeOrderBloc extends Bloc positions.add(OneTimeOrderPosition( role: role.roleId, count: role.workersNeeded, - startTime: _formatTime(shift.startsAt), - endTime: _formatTime(shift.endsAt), + startTime: formatTimeHHmm(shift.startsAt), + endTime: formatTimeHHmm(shift.endsAt), )); } } @@ -357,29 +362,4 @@ class OneTimeOrderBloc extends Bloc ), ); } - - /// Formats a [DateTime] to HH:mm string. - String _formatTime(DateTime dt) { - final DateTime local = dt.toLocal(); - return '${local.hour.toString().padLeft(2, '0')}:' - '${local.minute.toString().padLeft(2, '0')}'; - } - - /// Converts a break duration string to minutes. - int _breakMinutes(String value) { - switch (value) { - case 'MIN_10': - return 10; - case 'MIN_15': - return 15; - case 'MIN_30': - return 30; - case 'MIN_45': - return 45; - case 'MIN_60': - return 60; - default: - return 0; - } - } } 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 9115c729..fae8ee4d 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,26 +1,36 @@ +import 'package:client_create_order/src/domain/arguments/permanent_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_permanent_order_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_hubs_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_managers_by_hub_usecase.dart'; import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_roles_by_vendor_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_vendors_usecase.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; -import 'package:client_create_order/src/domain/arguments/permanent_order_arguments.dart'; import 'package:krow_domain/krow_domain.dart' as domain; import 'permanent_order_event.dart'; import 'permanent_order_state.dart'; /// BLoC for managing the permanent order creation form. +/// +/// Delegates all data fetching to query use cases and order submission +/// to [CreatePermanentOrderUseCase]. class PermanentOrderBloc extends Bloc with BlocErrorHandler, SafeBloc { + /// Creates a BLoC with required use case dependencies. PermanentOrderBloc( this._createPermanentOrderUseCase, this._getOrderDetailsForReorderUseCase, - this._queryRepository, + this._getVendorsUseCase, + this._getRolesByVendorUseCase, + this._getHubsUseCase, + this._getManagersByHubUseCase, ) : super(PermanentOrderState.initial()) { on(_onVendorsLoaded); on(_onVendorChanged); @@ -43,7 +53,10 @@ class PermanentOrderBloc extends Bloc final CreatePermanentOrderUseCase _createPermanentOrderUseCase; final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase; - final ClientOrderQueryRepositoryInterface _queryRepository; + final GetVendorsUseCase _getVendorsUseCase; + final GetRolesByVendorUseCase _getRolesByVendorUseCase; + final GetHubsUseCase _getHubsUseCase; + final GetManagersByHubUseCase _getManagersByHubUseCase; static const List _dayLabels = [ 'SUN', @@ -55,9 +68,10 @@ class PermanentOrderBloc extends Bloc 'SAT', ]; + /// Loads available vendors via the use case. Future _loadVendors() async { final List? vendors = await handleErrorWithResult( - action: () => _queryRepository.getVendors(), + action: () => _getVendorsUseCase(), onError: (_) => add(const PermanentOrderVendorsLoaded([])), ); @@ -66,6 +80,8 @@ class PermanentOrderBloc extends Bloc } } + /// Loads roles for [vendorId] via the use case and maps them to + /// presentation option models. Future _loadRolesForVendor( String vendorId, Emitter emit, @@ -73,7 +89,7 @@ class PermanentOrderBloc extends Bloc final List? roles = await handleErrorWithResult( action: () async { final List orderRoles = - await _queryRepository.getRolesByVendor(vendorId); + await _getRolesByVendorUseCase(vendorId); return orderRoles .map( (OrderRole r) => PermanentOrderRoleOption( @@ -93,10 +109,11 @@ class PermanentOrderBloc extends Bloc } } + /// Loads hubs via the use case and maps them to presentation option models. Future _loadHubs() async { final List? hubs = await handleErrorWithResult( action: () async { - final List orderHubs = await _queryRepository.getHubs(); + final List orderHubs = await _getHubsUseCase(); return orderHubs .map( (OrderHub hub) => PermanentOrderHubOption( @@ -193,6 +210,7 @@ class PermanentOrderBloc extends Bloc emit(state.copyWith(managers: event.managers)); } + /// Loads managers for [hubId] via the use case. Future _loadManagersForHub( String hubId, Emitter emit, @@ -201,7 +219,7 @@ class PermanentOrderBloc extends Bloc await handleErrorWithResult( action: () async { final List orderManagers = - await _queryRepository.getManagersByHub(hubId); + await _getManagersByHubUseCase(hubId); return orderManagers .map( (OrderManager m) => PermanentOrderManagerOption( @@ -221,7 +239,6 @@ class PermanentOrderBloc extends Bloc } } - void _onEventNameChanged( PermanentOrderEventNameChanged event, Emitter emit, @@ -315,6 +332,7 @@ class PermanentOrderBloc extends Bloc emit(state.copyWith(positions: newPositions)); } + /// Builds typed arguments from form state and submits via the use case. Future _onSubmitted( PermanentOrderSubmitted event, Emitter emit, @@ -328,16 +346,7 @@ class PermanentOrderBloc extends Bloc throw const domain.OrderMissingHubException(); } - final String startDate = - '${state.startDate.year.toString().padLeft(4, '0')}-' - '${state.startDate.month.toString().padLeft(2, '0')}-' - '${state.startDate.day.toString().padLeft(2, '0')}'; - - final List daysOfWeek = state.permanentDays - .map((String day) => _dayLabels.indexOf(day) % 7) - .toList(); - - final List> positions = + final List positionArgs = state.positions.map((PermanentOrderPosition p) { final PermanentOrderRoleOption? role = state.roles .cast() @@ -345,27 +354,24 @@ class PermanentOrderBloc extends Bloc (PermanentOrderRoleOption? r) => r != null && r.id == p.role, orElse: () => null, ); - return { - if (role != null) 'roleName': role.name, - if (p.role.isNotEmpty) 'roleId': p.role, - 'workerCount': p.count, - 'startTime': p.startTime, - 'endTime': p.endTime, - }; + return PermanentOrderPositionArgument( + roleId: p.role, + roleName: role?.name, + workerCount: p.count, + startTime: p.startTime, + endTime: p.endTime, + ); }).toList(); - final Map payload = { - 'hubId': selectedHub.id, - 'eventName': state.eventName, - 'startDate': startDate, - 'daysOfWeek': daysOfWeek, - 'positions': positions, - if (state.selectedVendor != null) - 'vendorId': state.selectedVendor!.id, - }; - await _createPermanentOrderUseCase( - PermanentOrderArguments(payload: payload), + PermanentOrderArguments( + hubId: selectedHub.id, + eventName: state.eventName, + startDate: state.startDate, + daysOfWeek: state.permanentDays, + positions: positionArgs, + vendorId: state.selectedVendor?.id, + ), ); emit(state.copyWith(status: PermanentOrderStatus.success)); }, @@ -376,6 +382,7 @@ class PermanentOrderBloc extends Bloc ); } + /// Initializes the form from route arguments or reorder preview data. Future _onInitialized( PermanentOrderInitialized event, Emitter emit, @@ -406,8 +413,8 @@ class PermanentOrderBloc extends Bloc positions.add(PermanentOrderPosition( role: role.roleId, count: role.workersNeeded, - startTime: _formatTime(shift.startsAt), - endTime: _formatTime(shift.endsAt), + startTime: formatTimeHHmm(shift.startsAt), + endTime: formatTimeHHmm(shift.endsAt), )); } } @@ -430,13 +437,6 @@ class PermanentOrderBloc extends Bloc ); } - /// Formats a [DateTime] to HH:mm string. - String _formatTime(DateTime dt) { - final DateTime local = dt.toLocal(); - return '${local.hour.toString().padLeft(2, '0')}:' - '${local.minute.toString().padLeft(2, '0')}'; - } - static List _sortDays(List days) { days.sort( (String a, String b) => 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 6154dc0c..ce226789 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,12 +1,15 @@ +import 'package:client_create_order/src/domain/arguments/recurring_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_recurring_order_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_hubs_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_managers_by_hub_usecase.dart'; import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_roles_by_vendor_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_vendors_usecase.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; -import 'package:client_create_order/src/domain/arguments/recurring_order_arguments.dart'; import 'package:krow_domain/krow_domain.dart' as domain; import 'recurring_order_event.dart'; @@ -14,19 +17,20 @@ import 'recurring_order_state.dart'; /// BLoC for managing the recurring order creation form. /// -/// Delegates all backend queries to [ClientOrderQueryRepositoryInterface] -/// and order submission to [CreateRecurringOrderUseCase]. -/// Builds V2 API payloads from form state. +/// Delegates all data fetching to query use cases and order submission +/// to [CreateRecurringOrderUseCase]. Builds V2 API payloads from form state. class RecurringOrderBloc extends Bloc with BlocErrorHandler, SafeBloc { - /// Creates a [RecurringOrderBloc] with the required use cases and - /// query repository. + /// Creates a [RecurringOrderBloc] with the required use case dependencies. RecurringOrderBloc( this._createRecurringOrderUseCase, this._getOrderDetailsForReorderUseCase, - this._queryRepository, + this._getVendorsUseCase, + this._getRolesByVendorUseCase, + this._getHubsUseCase, + this._getManagersByHubUseCase, ) : super(RecurringOrderState.initial()) { on(_onVendorsLoaded); on(_onVendorChanged); @@ -50,7 +54,10 @@ class RecurringOrderBloc extends Bloc final CreateRecurringOrderUseCase _createRecurringOrderUseCase; final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase; - final ClientOrderQueryRepositoryInterface _queryRepository; + final GetVendorsUseCase _getVendorsUseCase; + final GetRolesByVendorUseCase _getRolesByVendorUseCase; + final GetHubsUseCase _getHubsUseCase; + final GetManagersByHubUseCase _getManagersByHubUseCase; static const List _dayLabels = [ 'SUN', @@ -62,12 +69,10 @@ class RecurringOrderBloc extends Bloc 'SAT', ]; - /// Loads the list of available vendors from the query repository. + /// Loads the list of available vendors via the use case. Future _loadVendors() async { final List? vendors = await handleErrorWithResult( - action: () async { - return _queryRepository.getVendors(); - }, + action: () => _getVendorsUseCase(), onError: (_) => add(const RecurringOrderVendorsLoaded([])), ); @@ -77,8 +82,8 @@ class RecurringOrderBloc extends Bloc } } - /// Loads roles for the given [vendorId] and maps them to presentation - /// option models. + /// Loads roles for [vendorId] via the use case and maps them to + /// presentation option models. Future _loadRolesForVendor( String vendorId, Emitter emit, @@ -86,7 +91,7 @@ class RecurringOrderBloc extends Bloc final List? roles = await handleErrorWithResult( action: () async { final List orderRoles = - await _queryRepository.getRolesByVendor(vendorId); + await _getRolesByVendorUseCase(vendorId); return orderRoles .map( (OrderRole r) => RecurringOrderRoleOption( @@ -106,12 +111,12 @@ class RecurringOrderBloc extends Bloc } } - /// Loads team hubs for the current business owner and maps them to - /// presentation option models. + /// Loads team hubs via the use case and maps them to presentation + /// option models. Future _loadHubs() async { final List? hubs = await handleErrorWithResult( action: () async { - final List orderHubs = await _queryRepository.getHubs(); + final List orderHubs = await _getHubsUseCase(); return orderHubs .map( (OrderHub hub) => RecurringOrderHubOption( @@ -208,8 +213,8 @@ class RecurringOrderBloc extends Bloc emit(state.copyWith(managers: event.managers)); } - /// Loads managers for the given [hubId] and maps them to presentation - /// option models. + /// Loads managers for [hubId] via the use case and maps them to + /// presentation option models. Future _loadManagersForHub( String hubId, Emitter emit, @@ -218,7 +223,7 @@ class RecurringOrderBloc extends Bloc await handleErrorWithResult( action: () async { final List orderManagers = - await _queryRepository.getManagersByHub(hubId); + await _getManagersByHubUseCase(hubId); return orderManagers .map( (OrderManager m) => RecurringOrderManagerOption( @@ -347,6 +352,7 @@ class RecurringOrderBloc extends Bloc emit(state.copyWith(positions: newPositions)); } + /// Builds typed arguments from form state and submits via the use case. Future _onSubmitted( RecurringOrderSubmitted event, Emitter emit, @@ -360,21 +366,7 @@ class RecurringOrderBloc extends Bloc throw const domain.OrderMissingHubException(); } - final String startDate = - '${state.startDate.year.toString().padLeft(4, '0')}-' - '${state.startDate.month.toString().padLeft(2, '0')}-' - '${state.startDate.day.toString().padLeft(2, '0')}'; - final String endDate = - '${state.endDate.year.toString().padLeft(4, '0')}-' - '${state.endDate.month.toString().padLeft(2, '0')}-' - '${state.endDate.day.toString().padLeft(2, '0')}'; - - // Map day labels (MON=1, TUE=2, ..., SUN=0) to V2 int format - final List recurrenceDays = state.recurringDays - .map((String day) => _dayLabels.indexOf(day) % 7) - .toList(); - - final List> positions = + final List positionArgs = state.positions.map((RecurringOrderPosition p) { final RecurringOrderRoleOption? role = state.roles .cast() @@ -382,28 +374,25 @@ class RecurringOrderBloc extends Bloc (RecurringOrderRoleOption? r) => r != null && r.id == p.role, orElse: () => null, ); - return { - if (role != null) 'roleName': role.name, - if (p.role.isNotEmpty) 'roleId': p.role, - 'workerCount': p.count, - 'startTime': p.startTime, - 'endTime': p.endTime, - }; + return RecurringOrderPositionArgument( + roleId: p.role, + roleName: role?.name, + workerCount: p.count, + startTime: p.startTime, + endTime: p.endTime, + ); }).toList(); - final Map payload = { - 'hubId': selectedHub.id, - 'eventName': state.eventName, - 'startDate': startDate, - 'endDate': endDate, - 'recurrenceDays': recurrenceDays, - 'positions': positions, - if (state.selectedVendor != null) - 'vendorId': state.selectedVendor!.id, - }; - await _createRecurringOrderUseCase( - RecurringOrderArguments(payload: payload), + RecurringOrderArguments( + hubId: selectedHub.id, + eventName: state.eventName, + startDate: state.startDate, + endDate: state.endDate, + recurringDays: state.recurringDays, + positions: positionArgs, + vendorId: state.selectedVendor?.id, + ), ); emit(state.copyWith(status: RecurringOrderStatus.success)); }, @@ -414,6 +403,7 @@ class RecurringOrderBloc extends Bloc ); } + /// Initializes the form from route arguments or reorder preview data. Future _onInitialized( RecurringOrderInitialized event, Emitter emit, @@ -445,8 +435,8 @@ class RecurringOrderBloc extends Bloc positions.add(RecurringOrderPosition( role: role.roleId, count: role.workersNeeded, - startTime: _formatTime(shift.startsAt), - endTime: _formatTime(shift.endsAt), + startTime: formatTimeHHmm(shift.startsAt), + endTime: formatTimeHHmm(shift.endsAt), )); } } @@ -470,13 +460,6 @@ class RecurringOrderBloc extends Bloc ); } - /// Formats a [DateTime] to HH:mm string. - String _formatTime(DateTime dt) { - final DateTime local = dt.toLocal(); - return '${local.hour.toString().padLeft(2, '0')}:' - '${local.minute.toString().padLeft(2, '0')}'; - } - static List _sortDays(List days) { days.sort( (String a, String b) => diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart index 2f656e39..192b4384 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart @@ -1,13 +1,13 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/repositories/i_view_orders_repository.dart'; +import '../../domain/repositories/view_orders_repository_interface.dart'; -/// V2 API implementation of [IViewOrdersRepository]. +/// V2 API implementation of [ViewOrdersRepositoryInterface]. /// /// Replaces the old Data Connect implementation with [BaseApiService] calls /// to the V2 query and command API endpoints. -class ViewOrdersRepositoryImpl implements IViewOrdersRepository { +class ViewOrdersRepositoryImpl implements ViewOrdersRepositoryInterface { /// Creates an instance backed by the given [apiService]. ViewOrdersRepositoryImpl({required BaseApiService apiService}) : _api = apiService; diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/repositories/i_view_orders_repository.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/repositories/view_orders_repository_interface.dart similarity index 96% rename from apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/repositories/i_view_orders_repository.dart rename to apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/repositories/view_orders_repository_interface.dart index a2b86ccf..ecbc1bb0 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/repositories/i_view_orders_repository.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/repositories/view_orders_repository_interface.dart @@ -4,7 +4,7 @@ import 'package:krow_domain/krow_domain.dart'; /// /// V2 API returns workers inline with order items, so the separate /// accepted-applications method is no longer needed. -abstract class IViewOrdersRepository { +abstract class ViewOrdersRepositoryInterface { /// Fetches [OrderItem] list for the given date range via the V2 API. Future> getOrdersForRange({ required DateTime start, diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/usecases/get_orders_use_case.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/usecases/get_orders_use_case.dart index e8e9152f..9ba0b2aa 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/usecases/get_orders_use_case.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/usecases/get_orders_use_case.dart @@ -1,18 +1,18 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../repositories/i_view_orders_repository.dart'; +import '../repositories/view_orders_repository_interface.dart'; import '../arguments/orders_range_arguments.dart'; /// Use case for retrieving the list of client orders. /// /// This use case encapsulates the business rule of fetching orders -/// and delegates the data retrieval to the [IViewOrdersRepository]. +/// and delegates the data retrieval to the [ViewOrdersRepositoryInterface]. class GetOrdersUseCase implements UseCase> { - /// Creates a [GetOrdersUseCase] with the required [IViewOrdersRepository]. + /// Creates a [GetOrdersUseCase] with the required [ViewOrdersRepositoryInterface]. GetOrdersUseCase(this._repository); - final IViewOrdersRepository _repository; + final ViewOrdersRepositoryInterface _repository; @override Future> call(OrdersRangeArguments input) { diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart index fdb1cbc8..44add689 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart @@ -4,11 +4,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/repositories/i_view_orders_repository.dart'; +import '../../domain/repositories/view_orders_repository_interface.dart'; /// Bottom sheet for editing an existing order via the V2 API. /// -/// Delegates all backend calls through [IViewOrdersRepository]. +/// Delegates all backend calls through [ViewOrdersRepositoryInterface]. /// The V2 `clientOrderEdit` endpoint creates an edited copy. class OrderEditSheet extends StatefulWidget { /// Creates an [OrderEditSheet] for the given [order]. @@ -39,12 +39,12 @@ class OrderEditSheetState extends State { List> _hubs = const >[]; Map? _selectedHub; - late IViewOrdersRepository _repository; + late ViewOrdersRepositoryInterface _repository; @override void initState() { super.initState(); - _repository = Modular.get(); + _repository = Modular.get(); _orderNameController = TextEditingController(text: widget.order.roleName); final String startHH = @@ -441,9 +441,9 @@ class OrderEditSheetState extends State { const SizedBox(height: UiConstants.space3), // Role selector - _buildSectionHeader('ROLE'), + _buildSectionHeader('ROLE'), // TODO: localize _buildDropdown( - hint: 'Select role', + hint: 'Select role', // TODO: localize value: roleName.isNotEmpty ? roleName : null, items: _roles .map((Map r) => r['roleName'] as String? ?? r['name'] as String? ?? '') @@ -495,7 +495,7 @@ class OrderEditSheetState extends State { children: [ Expanded( child: _buildInlineTimeInput( - label: 'Start Time', + label: 'Start Time', // TODO: localize value: pos['startTime'] as String? ?? '09:00', onTap: () async { final TimeOfDay? picked = await showTimePicker( @@ -513,7 +513,7 @@ class OrderEditSheetState extends State { const SizedBox(width: UiConstants.space3), Expanded( child: _buildInlineTimeInput( - label: 'End Time', + label: 'End Time', // TODO: localize value: pos['endTime'] as String? ?? '17:00', onTap: () async { final TimeOfDay? picked = await showTimePicker( @@ -825,6 +825,7 @@ class OrderEditSheetState extends State { style: UiTypography.body2b.textPrimary, ), Text( + // TODO: localize '${pos['workerCount']} worker${(pos['workerCount'] as int? ?? 1) > 1 ? 's' : ''}', style: UiTypography.footnote2r.textSecondary, ), 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 7d56d1c2..bc6c8c76 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 @@ -4,7 +4,7 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'data/repositories/view_orders_repository_impl.dart'; -import 'domain/repositories/i_view_orders_repository.dart'; +import 'domain/repositories/view_orders_repository_interface.dart'; import 'domain/usecases/get_orders_use_case.dart'; import 'presentation/blocs/view_orders_cubit.dart'; import 'presentation/pages/view_orders_page.dart'; @@ -20,7 +20,7 @@ class ViewOrdersModule extends Module { @override void binds(Injector i) { // Repositories - i.add( + i.add( () => ViewOrdersRepositoryImpl( apiService: i.get(), ), diff --git a/apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart b/apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart index 40853550..d64857b8 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart @@ -3,12 +3,12 @@ import 'package:krow_domain/krow_domain.dart'; import 'package:client_reports/src/domain/repositories/reports_repository.dart'; -/// V2 API implementation of [ReportsRepository]. +/// V2 API implementation of [ReportsRepositoryInterface]. /// /// Each method hits its corresponding `ClientEndpoints.reports*` endpoint, /// passing date-range query parameters, and deserialises the JSON response /// into the relevant domain entity. -class ReportsRepositoryImpl implements ReportsRepository { +class ReportsRepositoryImpl implements ReportsRepositoryInterface { /// Creates a [ReportsRepositoryImpl]. ReportsRepositoryImpl({required BaseApiService apiService}) : _apiService = apiService; diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/arguments/daily_ops_arguments.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/arguments/daily_ops_arguments.dart new file mode 100644 index 00000000..08d65941 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/arguments/daily_ops_arguments.dart @@ -0,0 +1,13 @@ +import 'package:krow_core/core.dart'; + +/// Arguments for the daily operations report use case. +class DailyOpsArguments extends UseCaseArgument { + /// Creates [DailyOpsArguments]. + const DailyOpsArguments({required this.date}); + + /// The date to fetch the daily operations report for. + final DateTime date; + + @override + List get props => [date]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/arguments/date_range_arguments.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/arguments/date_range_arguments.dart new file mode 100644 index 00000000..82543820 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/arguments/date_range_arguments.dart @@ -0,0 +1,19 @@ +import 'package:krow_core/core.dart'; + +/// Arguments for use cases that require a date range (start and end dates). +class DateRangeArguments extends UseCaseArgument { + /// Creates [DateRangeArguments]. + const DateRangeArguments({ + required this.startDate, + required this.endDate, + }); + + /// Start of the reporting period. + final DateTime startDate; + + /// End of the reporting period. + final DateTime endDate; + + @override + List get props => [startDate, endDate]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/repositories/reports_repository.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/repositories/reports_repository.dart index aa096c67..195fa062 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/domain/repositories/reports_repository.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/repositories/reports_repository.dart @@ -1,7 +1,7 @@ import 'package:krow_domain/krow_domain.dart'; /// Contract for fetching report data from the V2 API. -abstract class ReportsRepository { +abstract class ReportsRepositoryInterface { /// Fetches the daily operations report for a given [date]. Future getDailyOpsReport({ required DateTime date, diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_coverage_report_usecase.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_coverage_report_usecase.dart new file mode 100644 index 00000000..43ed574e --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_coverage_report_usecase.dart @@ -0,0 +1,23 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/repositories/reports_repository.dart'; + +/// Fetches the coverage report for a date range. +class GetCoverageReportUseCase + implements UseCase { + /// Creates a [GetCoverageReportUseCase]. + GetCoverageReportUseCase(this._repository); + + /// The repository providing report data. + final ReportsRepositoryInterface _repository; + + @override + Future call(DateRangeArguments input) { + return _repository.getCoverageReport( + startDate: input.startDate, + endDate: input.endDate, + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_daily_ops_report_usecase.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_daily_ops_report_usecase.dart new file mode 100644 index 00000000..f22fbe94 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_daily_ops_report_usecase.dart @@ -0,0 +1,20 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:client_reports/src/domain/arguments/daily_ops_arguments.dart'; +import 'package:client_reports/src/domain/repositories/reports_repository.dart'; + +/// Fetches the daily operations report for a single date. +class GetDailyOpsReportUseCase + implements UseCase { + /// Creates a [GetDailyOpsReportUseCase]. + GetDailyOpsReportUseCase(this._repository); + + /// The repository providing report data. + final ReportsRepositoryInterface _repository; + + @override + Future call(DailyOpsArguments input) { + return _repository.getDailyOpsReport(date: input.date); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_forecast_report_usecase.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_forecast_report_usecase.dart new file mode 100644 index 00000000..458e9955 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_forecast_report_usecase.dart @@ -0,0 +1,23 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/repositories/reports_repository.dart'; + +/// Fetches the forecast report for a date range. +class GetForecastReportUseCase + implements UseCase { + /// Creates a [GetForecastReportUseCase]. + GetForecastReportUseCase(this._repository); + + /// The repository providing report data. + final ReportsRepositoryInterface _repository; + + @override + Future call(DateRangeArguments input) { + return _repository.getForecastReport( + startDate: input.startDate, + endDate: input.endDate, + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_no_show_report_usecase.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_no_show_report_usecase.dart new file mode 100644 index 00000000..08ad052d --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_no_show_report_usecase.dart @@ -0,0 +1,23 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/repositories/reports_repository.dart'; + +/// Fetches the no-show report for a date range. +class GetNoShowReportUseCase + implements UseCase { + /// Creates a [GetNoShowReportUseCase]. + GetNoShowReportUseCase(this._repository); + + /// The repository providing report data. + final ReportsRepositoryInterface _repository; + + @override + Future call(DateRangeArguments input) { + return _repository.getNoShowReport( + startDate: input.startDate, + endDate: input.endDate, + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_performance_report_usecase.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_performance_report_usecase.dart new file mode 100644 index 00000000..0845e454 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_performance_report_usecase.dart @@ -0,0 +1,23 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/repositories/reports_repository.dart'; + +/// Fetches the performance report for a date range. +class GetPerformanceReportUseCase + implements UseCase { + /// Creates a [GetPerformanceReportUseCase]. + GetPerformanceReportUseCase(this._repository); + + /// The repository providing report data. + final ReportsRepositoryInterface _repository; + + @override + Future call(DateRangeArguments input) { + return _repository.getPerformanceReport( + startDate: input.startDate, + endDate: input.endDate, + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_reports_summary_usecase.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_reports_summary_usecase.dart new file mode 100644 index 00000000..b270506b --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_reports_summary_usecase.dart @@ -0,0 +1,23 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/repositories/reports_repository.dart'; + +/// Fetches the high-level report summary for a date range. +class GetReportsSummaryUseCase + implements UseCase { + /// Creates a [GetReportsSummaryUseCase]. + GetReportsSummaryUseCase(this._repository); + + /// The repository providing report data. + final ReportsRepositoryInterface _repository; + + @override + Future call(DateRangeArguments input) { + return _repository.getReportsSummary( + startDate: input.startDate, + endDate: input.endDate, + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_spend_report_usecase.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_spend_report_usecase.dart new file mode 100644 index 00000000..b1783972 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_spend_report_usecase.dart @@ -0,0 +1,23 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/repositories/reports_repository.dart'; + +/// Fetches the spend report for a date range. +class GetSpendReportUseCase + implements UseCase { + /// Creates a [GetSpendReportUseCase]. + GetSpendReportUseCase(this._repository); + + /// The repository providing report data. + final ReportsRepositoryInterface _repository; + + @override + Future call(DateRangeArguments input) { + return _repository.getSpendReport( + startDate: input.startDate, + endDate: input.endDate, + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_bloc.dart index 7745e970..2eb9cc9f 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_bloc.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_bloc.dart @@ -1,22 +1,23 @@ -import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/usecases/get_coverage_report_usecase.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:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -/// BLoC that loads the [CoverageReport]. +/// BLoC that loads the [CoverageReport] via [GetCoverageReportUseCase]. class CoverageBloc extends Bloc with BlocErrorHandler { /// Creates a [CoverageBloc]. - CoverageBloc({required ReportsRepository reportsRepository}) - : _reportsRepository = reportsRepository, + CoverageBloc({required GetCoverageReportUseCase getCoverageReportUseCase}) + : _getCoverageReportUseCase = getCoverageReportUseCase, super(CoverageInitial()) { on(_onLoadCoverageReport); } - /// The repository used to fetch report data. - final ReportsRepository _reportsRepository; + /// The use case for fetching the coverage report. + final GetCoverageReportUseCase _getCoverageReportUseCase; Future _onLoadCoverageReport( LoadCoverageReport event, @@ -26,10 +27,11 @@ class CoverageBloc extends Bloc emit: emit, action: () async { emit(CoverageLoading()); - final CoverageReport report = - await _reportsRepository.getCoverageReport( - startDate: event.startDate, - endDate: event.endDate, + final CoverageReport report = await _getCoverageReportUseCase.call( + DateRangeArguments( + startDate: event.startDate, + endDate: event.endDate, + ), ); emit(CoverageLoaded(report)); }, diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_state.dart index 109a0c4c..381ac3e0 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_state.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_state.dart @@ -1,30 +1,39 @@ -// 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:equatable/equatable.dart'; +import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +/// Base state for the coverage report BLoC. abstract class CoverageState extends Equatable { + /// Creates a [CoverageState]. const CoverageState(); @override List get props => []; } +/// Initial state before any coverage report has been requested. class CoverageInitial extends CoverageState {} +/// State while the coverage report is loading. class CoverageLoading extends CoverageState {} +/// State when the coverage report has loaded successfully. class CoverageLoaded extends CoverageState { - + /// Creates a [CoverageLoaded] with the given [report]. const CoverageLoaded(this.report); + + /// The loaded coverage report data. final CoverageReport report; @override List get props => [report]; } +/// State when loading the coverage report has failed. class CoverageError extends CoverageState { - + /// Creates a [CoverageError] with the given error [message]. const CoverageError(this.message); + + /// The error message describing the failure. final String message; @override 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 511a2344..3190999d 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,22 +1,23 @@ -import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/domain/arguments/daily_ops_arguments.dart'; +import 'package:client_reports/src/domain/usecases/get_daily_ops_report_usecase.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:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -/// BLoC that loads the [DailyOpsReport]. +/// BLoC that loads the [DailyOpsReport] via [GetDailyOpsReportUseCase]. class DailyOpsBloc extends Bloc with BlocErrorHandler { /// Creates a [DailyOpsBloc]. - DailyOpsBloc({required ReportsRepository reportsRepository}) - : _reportsRepository = reportsRepository, + DailyOpsBloc({required GetDailyOpsReportUseCase getDailyOpsReportUseCase}) + : _getDailyOpsReportUseCase = getDailyOpsReportUseCase, super(DailyOpsInitial()) { on(_onLoadDailyOpsReport); } - /// The repository used to fetch report data. - final ReportsRepository _reportsRepository; + /// The use case for fetching the daily operations report. + final GetDailyOpsReportUseCase _getDailyOpsReportUseCase; Future _onLoadDailyOpsReport( LoadDailyOpsReport event, @@ -26,9 +27,8 @@ class DailyOpsBloc extends Bloc emit: emit, action: () async { emit(DailyOpsLoading()); - final DailyOpsReport report = - await _reportsRepository.getDailyOpsReport( - date: event.date, + final DailyOpsReport report = await _getDailyOpsReportUseCase.call( + DailyOpsArguments(date: event.date), ); emit(DailyOpsLoaded(report)); }, diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_state.dart index 85fa3fee..3063b192 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_state.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_state.dart @@ -1,30 +1,39 @@ -// 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:equatable/equatable.dart'; +import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +/// Base state for the daily operations report BLoC. abstract class DailyOpsState extends Equatable { + /// Creates a [DailyOpsState]. const DailyOpsState(); @override List get props => []; } +/// Initial state before any report has been requested. class DailyOpsInitial extends DailyOpsState {} +/// State while the daily operations report is loading. class DailyOpsLoading extends DailyOpsState {} +/// State when the daily operations report has loaded successfully. class DailyOpsLoaded extends DailyOpsState { - + /// Creates a [DailyOpsLoaded] with the given [report]. const DailyOpsLoaded(this.report); + + /// The loaded daily operations report data. final DailyOpsReport report; @override List get props => [report]; } +/// State when loading the daily operations report has failed. class DailyOpsError extends DailyOpsState { - + /// Creates a [DailyOpsError] with the given error [message]. const DailyOpsError(this.message); + + /// The error message describing the failure. final String message; @override diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_bloc.dart index cc985817..83d99323 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_bloc.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_bloc.dart @@ -1,22 +1,23 @@ -import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/usecases/get_forecast_report_usecase.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:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -/// BLoC that loads the [ForecastReport]. +/// BLoC that loads the [ForecastReport] via [GetForecastReportUseCase]. class ForecastBloc extends Bloc with BlocErrorHandler { /// Creates a [ForecastBloc]. - ForecastBloc({required ReportsRepository reportsRepository}) - : _reportsRepository = reportsRepository, + ForecastBloc({required GetForecastReportUseCase getForecastReportUseCase}) + : _getForecastReportUseCase = getForecastReportUseCase, super(ForecastInitial()) { on(_onLoadForecastReport); } - /// The repository used to fetch report data. - final ReportsRepository _reportsRepository; + /// The use case for fetching the forecast report. + final GetForecastReportUseCase _getForecastReportUseCase; Future _onLoadForecastReport( LoadForecastReport event, @@ -26,10 +27,11 @@ class ForecastBloc extends Bloc emit: emit, action: () async { emit(ForecastLoading()); - final ForecastReport report = - await _reportsRepository.getForecastReport( - startDate: event.startDate, - endDate: event.endDate, + final ForecastReport report = await _getForecastReportUseCase.call( + DateRangeArguments( + startDate: event.startDate, + endDate: event.endDate, + ), ); emit(ForecastLoaded(report)); }, diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_state.dart index ae252a4e..53ac6dbe 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_state.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_state.dart @@ -1,30 +1,39 @@ -// 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:equatable/equatable.dart'; +import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +/// Base state for the forecast report BLoC. abstract class ForecastState extends Equatable { + /// Creates a [ForecastState]. const ForecastState(); @override List get props => []; } +/// Initial state before any forecast has been requested. class ForecastInitial extends ForecastState {} +/// State while the forecast report is loading. class ForecastLoading extends ForecastState {} +/// State when the forecast report has loaded successfully. class ForecastLoaded extends ForecastState { - + /// Creates a [ForecastLoaded] with the given [report]. const ForecastLoaded(this.report); + + /// The loaded forecast report data. final ForecastReport report; @override List get props => [report]; } +/// State when loading the forecast report has failed. class ForecastError extends ForecastState { - + /// Creates a [ForecastError] with the given error [message]. const ForecastError(this.message); + + /// The error message describing the failure. final String message; @override diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_bloc.dart index 000ada91..00092c71 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_bloc.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_bloc.dart @@ -1,22 +1,23 @@ -import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/usecases/get_no_show_report_usecase.dart'; import 'package:client_reports/src/presentation/blocs/no_show/no_show_event.dart'; import 'package:client_reports/src/presentation/blocs/no_show/no_show_state.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -/// BLoC that loads the [NoShowReport]. +/// BLoC that loads the [NoShowReport] via [GetNoShowReportUseCase]. class NoShowBloc extends Bloc with BlocErrorHandler { /// Creates a [NoShowBloc]. - NoShowBloc({required ReportsRepository reportsRepository}) - : _reportsRepository = reportsRepository, + NoShowBloc({required GetNoShowReportUseCase getNoShowReportUseCase}) + : _getNoShowReportUseCase = getNoShowReportUseCase, super(NoShowInitial()) { on(_onLoadNoShowReport); } - /// The repository used to fetch report data. - final ReportsRepository _reportsRepository; + /// The use case for fetching the no-show report. + final GetNoShowReportUseCase _getNoShowReportUseCase; Future _onLoadNoShowReport( LoadNoShowReport event, @@ -26,9 +27,11 @@ class NoShowBloc extends Bloc emit: emit, action: () async { emit(NoShowLoading()); - final NoShowReport report = await _reportsRepository.getNoShowReport( - startDate: event.startDate, - endDate: event.endDate, + final NoShowReport report = await _getNoShowReportUseCase.call( + DateRangeArguments( + startDate: event.startDate, + endDate: event.endDate, + ), ); emit(NoShowLoaded(report)); }, diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_state.dart index 8e286465..c761dea0 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_state.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_state.dart @@ -1,30 +1,39 @@ -// 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:equatable/equatable.dart'; +import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +/// Base state for the no-show report BLoC. abstract class NoShowState extends Equatable { + /// Creates a [NoShowState]. const NoShowState(); @override List get props => []; } +/// Initial state before any no-show report has been requested. class NoShowInitial extends NoShowState {} +/// State while the no-show report is loading. class NoShowLoading extends NoShowState {} +/// State when the no-show report has loaded successfully. class NoShowLoaded extends NoShowState { - + /// Creates a [NoShowLoaded] with the given [report]. const NoShowLoaded(this.report); + + /// The loaded no-show report data. final NoShowReport report; @override List get props => [report]; } +/// State when loading the no-show report has failed. class NoShowError extends NoShowState { - + /// Creates a [NoShowError] with the given error [message]. const NoShowError(this.message); + + /// The error message describing the failure. final String message; @override diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_bloc.dart index b64f09ef..cab097a2 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_bloc.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_bloc.dart @@ -1,22 +1,24 @@ -import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/usecases/get_performance_report_usecase.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:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -/// BLoC that loads the [PerformanceReport]. +/// BLoC that loads the [PerformanceReport] via [GetPerformanceReportUseCase]. class PerformanceBloc extends Bloc with BlocErrorHandler { /// Creates a [PerformanceBloc]. - PerformanceBloc({required ReportsRepository reportsRepository}) - : _reportsRepository = reportsRepository, + PerformanceBloc({ + required GetPerformanceReportUseCase getPerformanceReportUseCase, + }) : _getPerformanceReportUseCase = getPerformanceReportUseCase, super(PerformanceInitial()) { on(_onLoadPerformanceReport); } - /// The repository used to fetch report data. - final ReportsRepository _reportsRepository; + /// The use case for fetching the performance report. + final GetPerformanceReportUseCase _getPerformanceReportUseCase; Future _onLoadPerformanceReport( LoadPerformanceReport event, @@ -26,10 +28,11 @@ class PerformanceBloc extends Bloc emit: emit, action: () async { emit(PerformanceLoading()); - final PerformanceReport report = - await _reportsRepository.getPerformanceReport( - startDate: event.startDate, - endDate: event.endDate, + final PerformanceReport report = await _getPerformanceReportUseCase.call( + DateRangeArguments( + startDate: event.startDate, + endDate: event.endDate, + ), ); emit(PerformanceLoaded(report)); }, diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_state.dart index e6ca9527..bc47680b 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_state.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_state.dart @@ -1,30 +1,39 @@ -// 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:equatable/equatable.dart'; +import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +/// Base state for the performance report BLoC. abstract class PerformanceState extends Equatable { + /// Creates a [PerformanceState]. const PerformanceState(); @override List get props => []; } +/// Initial state before any performance report has been requested. class PerformanceInitial extends PerformanceState {} +/// State while the performance report is loading. class PerformanceLoading extends PerformanceState {} +/// State when the performance report has loaded successfully. class PerformanceLoaded extends PerformanceState { - + /// Creates a [PerformanceLoaded] with the given [report]. const PerformanceLoaded(this.report); + + /// The loaded performance report data. final PerformanceReport report; @override List get props => [report]; } +/// State when loading the performance report has failed. class PerformanceError extends PerformanceState { - + /// Creates a [PerformanceError] with the given error [message]. const PerformanceError(this.message); + + /// The error message describing the failure. final String message; @override diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_bloc.dart index e64c04cf..e2efff90 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_bloc.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_bloc.dart @@ -1,22 +1,23 @@ -import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/usecases/get_spend_report_usecase.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:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -/// BLoC that loads the [SpendReport]. +/// BLoC that loads the [SpendReport] via [GetSpendReportUseCase]. class SpendBloc extends Bloc with BlocErrorHandler { /// Creates a [SpendBloc]. - SpendBloc({required ReportsRepository reportsRepository}) - : _reportsRepository = reportsRepository, + SpendBloc({required GetSpendReportUseCase getSpendReportUseCase}) + : _getSpendReportUseCase = getSpendReportUseCase, super(SpendInitial()) { on(_onLoadSpendReport); } - /// The repository used to fetch report data. - final ReportsRepository _reportsRepository; + /// The use case for fetching the spend report. + final GetSpendReportUseCase _getSpendReportUseCase; Future _onLoadSpendReport( LoadSpendReport event, @@ -26,9 +27,11 @@ class SpendBloc extends Bloc emit: emit, action: () async { emit(SpendLoading()); - final SpendReport report = await _reportsRepository.getSpendReport( - startDate: event.startDate, - endDate: event.endDate, + final SpendReport report = await _getSpendReportUseCase.call( + DateRangeArguments( + startDate: event.startDate, + endDate: event.endDate, + ), ); emit(SpendLoaded(report)); }, diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_state.dart index f8c949cd..011f1b66 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_state.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_state.dart @@ -1,30 +1,39 @@ -// 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:equatable/equatable.dart'; +import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +/// Base state for the spend report BLoC. abstract class SpendState extends Equatable { + /// Creates a [SpendState]. const SpendState(); @override List get props => []; } +/// Initial state before any spend report has been requested. class SpendInitial extends SpendState {} +/// State while the spend report is loading. class SpendLoading extends SpendState {} +/// State when the spend report has loaded successfully. class SpendLoaded extends SpendState { - + /// Creates a [SpendLoaded] with the given [report]. const SpendLoaded(this.report); + + /// The loaded spend report data. final SpendReport report; @override List get props => [report]; } +/// State when loading the spend report has failed. class SpendError extends SpendState { - + /// Creates a [SpendError] with the given error [message]. const SpendError(this.message); + + /// The error message describing the failure. final String message; @override diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_bloc.dart index 4456877f..3f46e9b2 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_bloc.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_bloc.dart @@ -1,23 +1,25 @@ -import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/usecases/get_reports_summary_usecase.dart'; import 'package:client_reports/src/presentation/blocs/summary/reports_summary_event.dart'; import 'package:client_reports/src/presentation/blocs/summary/reports_summary_state.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -/// BLoC that loads the high-level [ReportSummary] for the reports dashboard. +/// BLoC that loads the high-level [ReportSummary] via [GetReportsSummaryUseCase]. class ReportsSummaryBloc extends Bloc with BlocErrorHandler { /// Creates a [ReportsSummaryBloc]. - ReportsSummaryBloc({required ReportsRepository reportsRepository}) - : _reportsRepository = reportsRepository, + ReportsSummaryBloc({ + required GetReportsSummaryUseCase getReportsSummaryUseCase, + }) : _getReportsSummaryUseCase = getReportsSummaryUseCase, super(ReportsSummaryInitial()) { on(_onLoadReportsSummary); } - /// The repository used to fetch summary data. - final ReportsRepository _reportsRepository; + /// The use case for fetching the report summary. + final GetReportsSummaryUseCase _getReportsSummaryUseCase; Future _onLoadReportsSummary( LoadReportsSummary event, @@ -27,10 +29,11 @@ class ReportsSummaryBloc emit: emit, action: () async { emit(ReportsSummaryLoading()); - final ReportSummary summary = - await _reportsRepository.getReportsSummary( - startDate: event.startDate, - endDate: event.endDate, + final ReportSummary summary = await _getReportsSummaryUseCase.call( + DateRangeArguments( + startDate: event.startDate, + endDate: event.endDate, + ), ); emit(ReportsSummaryLoaded(summary)); }, 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 35b7784f..0f80583c 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,6 +1,7 @@ -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:client_reports/src/presentation/widgets/report_detail_skeleton.dart'; import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; @@ -10,9 +11,9 @@ import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart'; - +/// Page displaying the coverage report with summary and daily breakdown. class CoverageReportPage extends StatefulWidget { + /// Creates a [CoverageReportPage]. const CoverageReportPage({super.key}); @override @@ -86,17 +87,14 @@ class _CoverageReportPageState extends State { children: [ Text( context.t.client_reports.coverage_report.title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, + style: UiTypography.title1b.copyWith( color: UiColors.white, ), ), Text( context.t.client_reports.coverage_report .subtitle, - style: TextStyle( - fontSize: 12, + style: UiTypography.body3r.copyWith( color: UiColors.white.withOpacity(0.7), ), ), @@ -143,9 +141,7 @@ class _CoverageReportPageState extends State { // Daily List Text( context.t.client_reports.coverage_report.next_7_days, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, + style: UiTypography.body3b.copyWith( color: UiColors.textSecondary, letterSpacing: 1.2, ), @@ -177,17 +173,25 @@ class _CoverageReportPageState extends State { } } +/// Summary card for coverage metrics with icon and value. class _CoverageSummaryCard extends StatelessWidget { - const _CoverageSummaryCard({ required this.label, required this.value, required this.icon, required this.color, }); + + /// The metric label text. final String label; + + /// The metric value text. final String value; + + /// The icon to display. final IconData icon; + + /// The icon and accent color. final Color color; @override @@ -216,26 +220,42 @@ class _CoverageSummaryCard extends StatelessWidget { child: Icon(icon, size: 16, color: color), ), const SizedBox(height: 12), - Text(label, style: const TextStyle(fontSize: 12, color: UiColors.textSecondary)), + Text( + label, + style: UiTypography.body3r.copyWith( + color: UiColors.textSecondary, + ), + ), const SizedBox(height: 4), - Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + Text( + value, + style: UiTypography.headline3b, + ), ], ), ); } } +/// List item showing daily coverage with progress bar. class _CoverageListItem extends StatelessWidget { - const _CoverageListItem({ required this.date, required this.needed, required this.filled, required this.percentage, }); + + /// The formatted date string. final String date; + + /// The number of workers needed. final int needed; + + /// The number of workers filled. final int filled; + + /// The coverage percentage. final double percentage; @override @@ -262,7 +282,10 @@ class _CoverageListItem extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(date, style: const TextStyle(fontWeight: FontWeight.bold)), + Text( + date, + style: UiTypography.body2b, + ), const SizedBox(height: 4), // Progress Bar ClipRRect( @@ -283,13 +306,11 @@ class _CoverageListItem extends StatelessWidget { children: [ Text( '$filled/$needed', - style: const TextStyle(fontWeight: FontWeight.bold), + style: UiTypography.body2b, ), Text( '${percentage.toStringAsFixed(0)}%', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, + style: UiTypography.body3b.copyWith( color: statusColor, ), ), @@ -300,4 +321,3 @@ class _CoverageListItem extends StatelessWidget { ); } } - 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 062e03ee..7910c1f0 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,6 +1,7 @@ -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:client_reports/src/presentation/widgets/report_detail_skeleton.dart'; import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; @@ -10,9 +11,9 @@ import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart'; - +/// Page displaying the daily operations report with shift stats and list. class DailyOpsReportPage extends StatefulWidget { + /// Creates a [DailyOpsReportPage]. const DailyOpsReportPage({super.key}); @override @@ -117,17 +118,14 @@ class _DailyOpsReportPageState extends State { Text( context.t.client_reports.daily_ops_report .title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, + style: UiTypography.title1b.copyWith( color: UiColors.white, ), ), Text( context.t.client_reports.daily_ops_report .subtitle, - style: TextStyle( - fontSize: 12, + style: UiTypography.body3r.copyWith( color: UiColors.white.withOpacity(0.7), ), ), @@ -135,52 +133,6 @@ class _DailyOpsReportPageState extends State { ), ], ), -/* - GestureDetector( - onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.t.client_reports.daily_ops_report - .placeholders.export_message, - ), - duration: const Duration(seconds: 2), - ), - ); - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - const Icon( - UiIcons.download, - size: 14, - color: UiColors.primary, - ), - const SizedBox(width: 6), - Text( - context.t.client_reports.quick_reports - .export_all - .split(' ') - .first, - style: const TextStyle( - color: UiColors.primary, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), -*/ ], ), ), @@ -223,10 +175,7 @@ class _DailyOpsReportPageState extends State { Text( DateFormat('MMM dd, yyyy') .format(_selectedDate), - style: const TextStyle( - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, - ), + style: UiTypography.body2b, ), ], ), @@ -325,10 +274,7 @@ class _DailyOpsReportPageState extends State { context.t.client_reports.daily_ops_report .all_shifts_title .toUpperCase(), - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, + style: UiTypography.body2b.copyWith( letterSpacing: 0.5, ), ), @@ -377,8 +323,8 @@ class _DailyOpsReportPageState extends State { } } +/// Stat card showing a metric with icon, value, and colored badge. class _OpsStatCard extends StatelessWidget { - const _OpsStatCard({ required this.label, required this.value, @@ -386,10 +332,20 @@ class _OpsStatCard extends StatelessWidget { required this.color, required this.icon, }); + + /// The metric label text. final String label; + + /// The metric value text. final String value; + + /// The badge sub-value text. final String subValue; + + /// The theme color for icon and badge. final Color color; + + /// The icon to display. final IconData icon; @override @@ -412,10 +368,8 @@ class _OpsStatCard extends StatelessWidget { Expanded( child: Text( label, - style: const TextStyle( - fontSize: 12, + style: UiTypography.body3m.copyWith( color: UiColors.textSecondary, - fontWeight: FontWeight.w600, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -428,15 +382,8 @@ class _OpsStatCard extends StatelessWidget { children: [ Text( value, - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, - ), + style: UiTypography.display1b, ), - - //UiChip(label: subValue), - // Colored pill badge (matches prototype) Container( padding: const EdgeInsets.symmetric( horizontal: 8, @@ -448,9 +395,7 @@ class _OpsStatCard extends StatelessWidget { ), child: Text( subValue, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, + style: UiTypography.body3b.copyWith( color: color, ), ), @@ -463,8 +408,8 @@ class _OpsStatCard extends StatelessWidget { } } +/// A single shift row in the daily operations list. class _ShiftListItem extends StatelessWidget { - const _ShiftListItem({ required this.title, required this.location, @@ -474,12 +419,26 @@ class _ShiftListItem extends StatelessWidget { required this.status, required this.statusColor, }); + + /// The shift role name. final String title; + + /// The shift location or ID. final String location; + + /// The formatted time range string. final String time; + + /// The workers ratio string (e.g. "3/5"). final String workers; + + /// The rate string. final String rate; + + /// The status label text. final String status; + + /// The color for the status badge. final Color statusColor; @override @@ -508,11 +467,7 @@ class _ShiftListItem extends StatelessWidget { children: [ Text( title, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - color: UiColors.textPrimary, - ), + style: UiTypography.body2b, ), const SizedBox(height: 4), Row( @@ -526,8 +481,7 @@ class _ShiftListItem extends StatelessWidget { Expanded( child: Text( location, - style: const TextStyle( - fontSize: 11, + style: UiTypography.titleUppercase4m.copyWith( color: UiColors.textSecondary, ), maxLines: 1, @@ -548,10 +502,8 @@ class _ShiftListItem extends StatelessWidget { ), child: Text( status.toUpperCase(), - style: TextStyle( + style: UiTypography.footnote2b.copyWith( color: statusColor, - fontSize: 10, - fontWeight: FontWeight.bold, ), ), ), @@ -585,6 +537,7 @@ class _ShiftListItem extends StatelessWidget { ); } + /// Builds a small info item with icon, label, and value. Widget _infoItem( BuildContext context, IconData icon, String label, String value) { return Row( @@ -596,13 +549,13 @@ class _ShiftListItem extends StatelessWidget { children: [ Text( label, - style: const TextStyle(fontSize: 10, color: UiColors.pinInactive), + style: UiTypography.footnote2r.copyWith( + color: UiColors.textInactive, + ), ), Text( value, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, + style: UiTypography.titleUppercase4b.copyWith( color: UiColors.textDescription, ), ), @@ -612,4 +565,3 @@ class _ShiftListItem extends StatelessWidget { ); } } - 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 5856b82e..eef44b28 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 @@ -100,12 +100,13 @@ class _ForecastReportPageState extends State { ); } + /// Builds the gradient header with back button and title. Widget _buildHeader(BuildContext context) { return Container( padding: const EdgeInsets.only(top: 60, left: 20, right: 20, bottom: 40), decoration: const BoxDecoration( gradient: LinearGradient( - colors: [UiColors.primary, Color(0xFF0020A0)], + colors: [UiColors.primary, UiColors.buttonPrimaryHover], begin: Alignment.topLeft, end: Alignment.bottomRight, ), @@ -150,6 +151,7 @@ class _ForecastReportPageState extends State { ); } + /// Builds the 2x2 metrics grid. Widget _buildMetricsGrid(BuildContext context, ForecastReport report) { final TranslationsClientReportsForecastReportEn t = context.t.client_reports.forecast_report; @@ -186,8 +188,8 @@ class _ForecastReportPageState extends State { label: t.metrics.total_shifts, value: report.totalShifts.toString(), badgeText: t.badges.scheduled, - iconColor: const Color(0xFF9333EA), - badgeColor: const Color(0xFFF3E8FF), + iconColor: UiColors.primary, + badgeColor: UiColors.tagInProgress, ), _MetricCard( icon: UiIcons.users, @@ -201,6 +203,7 @@ class _ForecastReportPageState extends State { ); } + /// Builds the chart section with weekly spend trend. Widget _buildChartSection(BuildContext context, ForecastReport report) { return Container( height: 320, @@ -231,13 +234,14 @@ class _ForecastReportPageState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ for (int i = 0; i < report.weeks.length; i++) ...[ - Text('W${i + 1}', - style: const TextStyle( - color: UiColors.textSecondary, fontSize: 12)), + Text( + 'W${i + 1}', + style: UiTypography.body3r.copyWith( + color: UiColors.textSecondary, + ), + ), if (i < report.weeks.length - 1) - const Text('', - style: TextStyle( - color: UiColors.transparent, fontSize: 12)), + const SizedBox.shrink(), ], ], ), @@ -247,6 +251,7 @@ class _ForecastReportPageState extends State { } } +/// Metric card widget for the forecast grid. class _MetricCard extends StatelessWidget { const _MetricCard({ required this.icon, @@ -257,11 +262,22 @@ class _MetricCard extends StatelessWidget { required this.badgeColor, }); + /// The metric icon. final IconData icon; + + /// The metric label text. final String label; + + /// The metric value text. final String value; + + /// The badge text. final String badgeText; + + /// The icon tint color. final Color iconColor; + + /// The badge background color. final Color badgeColor; @override @@ -308,11 +324,7 @@ class _MetricCard extends StatelessWidget { ), child: Text( badgeText, - style: UiTypography.footnote1r.copyWith( - color: UiColors.textPrimary, - fontSize: 10, - fontWeight: FontWeight.w600, - ), + style: UiTypography.footnote2b, ), ), ], @@ -328,7 +340,10 @@ class _WeeklyBreakdownItem extends StatelessWidget { required this.weekIndex, }); + /// The forecast week data. final ForecastWeek week; + + /// The 1-based week index. final int weekIndex; @override @@ -386,6 +401,7 @@ class _WeeklyBreakdownItem extends StatelessWidget { ); } + /// Builds a label/value stat column. Widget _buildStat(String label, String value) { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -402,6 +418,7 @@ class _WeeklyBreakdownItem extends StatelessWidget { class _ForecastChart extends StatelessWidget { const _ForecastChart({required this.weeks}); + /// The weekly forecast data points. final List weeks; @override 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 0f731caf..2cdd6c52 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,19 +1,19 @@ -// 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'; import 'package:client_reports/src/presentation/blocs/no_show/no_show_state.dart'; +import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.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 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart'; - +/// Page displaying the no-show report with summary metrics and worker cards. class NoShowReportPage extends StatefulWidget { + /// Creates a [NoShowReportPage]. const NoShowReportPage({super.key}); @override @@ -26,7 +26,7 @@ class _NoShowReportPageState extends State { @override Widget build(BuildContext context) { - return BlocProvider( + return BlocProvider( create: (BuildContext context) => Modular.get() ..add(LoadNoShowReport(startDate: _startDate, endDate: _endDate)), child: Scaffold( @@ -90,16 +90,13 @@ class _NoShowReportPageState extends State { children: [ Text( context.t.client_reports.no_show_report.title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, + style: UiTypography.title1b.copyWith( color: UiColors.white, ), ), Text( context.t.client_reports.no_show_report.subtitle, - style: TextStyle( - fontSize: 12, + style: UiTypography.body3r.copyWith( color: UiColors.white.withOpacity(0.6), ), ), @@ -107,47 +104,6 @@ class _NoShowReportPageState extends State { ), ], ), - // Export button -/* - GestureDetector( - onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Export coming soon'), - duration: Duration(seconds: 2), - ), - ); - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(8), - ), - child: const Row( - children: [ - Icon( - UiIcons.download, - size: 14, - color: Color(0xFF1A1A2E), - ), - SizedBox(width: 6), - Text( - 'Export', - style: TextStyle( - color: Color(0xFF1A1A2E), - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), -*/ ], ), ), @@ -159,7 +115,7 @@ class _NoShowReportPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 3-chip summary row (matches prototype) + // 3-chip summary row Row( children: [ Expanded( @@ -198,9 +154,7 @@ class _NoShowReportPageState extends State { Text( context.t.client_reports.no_show_report .workers_list_title, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, + style: UiTypography.body3b.copyWith( color: UiColors.textSecondary, letterSpacing: 1.2, ), @@ -214,7 +168,7 @@ class _NoShowReportPageState extends State { alignment: Alignment.center, child: Text( context.t.client_reports.no_show_report.empty_state, - style: const TextStyle( + style: UiTypography.body2r.copyWith( color: UiColors.textSecondary, ), ), @@ -241,18 +195,25 @@ class _NoShowReportPageState extends State { } } -// Summary chip (top 3 stats) +/// Summary chip showing a single metric with icon. class _SummaryChip extends StatelessWidget { - const _SummaryChip({ required this.icon, required this.iconColor, required this.label, required this.value, }); + + /// The icon to display. final IconData icon; + + /// The icon and label color. final Color iconColor; + + /// The metric label text. final String label; + + /// The metric value text. final String value; @override @@ -280,10 +241,8 @@ class _SummaryChip extends StatelessWidget { Expanded( child: Text( label, - style: TextStyle( - fontSize: 10, + style: UiTypography.footnote2b.copyWith( color: iconColor, - fontWeight: FontWeight.w600, ), overflow: TextOverflow.ellipsis, ), @@ -293,11 +252,7 @@ class _SummaryChip extends StatelessWidget { const SizedBox(height: 8), Text( value, - style: const TextStyle( - fontSize: 26, - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, - ), + style: UiTypography.display1b, ), ], ), @@ -305,24 +260,28 @@ class _SummaryChip extends StatelessWidget { } } -// ” Worker card with risk badge + latest incident ”””””””””””””” +/// Worker card with risk badge and latest incident date. class _WorkerCard extends StatelessWidget { - const _WorkerCard({required this.worker}); + + /// The worker item data. final NoShowWorkerItem worker; + /// Returns the localized risk label. String _riskLabel(BuildContext context, String riskStatus) { if (riskStatus == 'HIGH') return context.t.client_reports.no_show_report.risks.high; if (riskStatus == 'MEDIUM') return context.t.client_reports.no_show_report.risks.medium; return context.t.client_reports.no_show_report.risks.low; } + /// Returns the color for the given risk status. Color _riskColor(String riskStatus) { if (riskStatus == 'HIGH') return UiColors.error; if (riskStatus == 'MEDIUM') return UiColors.textWarning; return UiColors.success; } + /// Returns the background color for the given risk status. Color _riskBg(String riskStatus) { if (riskStatus == 'HIGH') return UiColors.tagError; if (riskStatus == 'MEDIUM') return UiColors.tagPending; @@ -374,16 +333,11 @@ class _WorkerCard extends StatelessWidget { children: [ Text( worker.staffName, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - color: UiColors.textPrimary, - ), + style: UiTypography.body2b, ), Text( context.t.client_reports.no_show_report.no_show_count(count: worker.incidentCount.toString()), - style: const TextStyle( - fontSize: 12, + style: UiTypography.body3r.copyWith( color: UiColors.textSecondary, ), ), @@ -403,9 +357,7 @@ class _WorkerCard extends StatelessWidget { ), child: Text( riskLabel, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, + style: UiTypography.titleUppercase4b.copyWith( color: riskColor, ), ), @@ -420,8 +372,7 @@ class _WorkerCard extends StatelessWidget { children: [ Text( context.t.client_reports.no_show_report.latest_incident, - style: const TextStyle( - fontSize: 11, + style: UiTypography.titleUppercase4m.copyWith( color: UiColors.textSecondary, ), ), @@ -430,10 +381,8 @@ class _WorkerCard extends StatelessWidget { ? DateFormat('MMM dd, yyyy') .format(worker.incidents.first.date) : '-', - style: const TextStyle( - fontSize: 11, + style: UiTypography.titleUppercase4m.copyWith( color: UiColors.textSecondary, - fontWeight: FontWeight.w500, ), ), ], @@ -443,6 +392,3 @@ class _WorkerCard extends StatelessWidget { ); } } - -// Insight line - 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 9c1c02e8..1b27bc4c 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,6 +1,7 @@ -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:client_reports/src/presentation/widgets/report_detail_skeleton.dart'; import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; @@ -9,9 +10,9 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart'; - +/// Page displaying the performance report with overall score and KPI breakdown. class PerformanceReportPage extends StatefulWidget { + /// Creates a [PerformanceReportPage]. const PerformanceReportPage({super.key}); @override @@ -24,7 +25,7 @@ class _PerformanceReportPageState extends State { @override Widget build(BuildContext context) { - return BlocProvider( + return BlocProvider( create: (BuildContext context) => Modular.get() ..add(LoadPerformanceReport(startDate: _startDate, endDate: _endDate)), child: Scaffold( @@ -102,18 +103,18 @@ class _PerformanceReportPageState extends State { ), _KpiData( icon: UiIcons.clock, - iconColor: const Color(0xFF9B59B6), + iconColor: UiColors.primary, label: context.t.client_reports.performance_report.kpis.on_time_rate, target: context.t.client_reports.performance_report.kpis.target_percent(percent: '97'), value: onTimeRate, displayValue: '${onTimeRate.toStringAsFixed(0)}%', - barColor: const Color(0xFF9B59B6), + barColor: UiColors.primary, met: onTimeRate >= 97, close: onTimeRate >= 92, ), _KpiData( icon: UiIcons.trendingUp, - iconColor: const Color(0xFFF39C12), + iconColor: UiColors.textWarning, label: context.t.client_reports.performance_report.kpis.avg_fill_time, target: context.t.client_reports.performance_report.kpis.target_hours(hours: '3'), value: avgFillTimeHours == 0 @@ -121,7 +122,7 @@ class _PerformanceReportPageState extends State { : (3 / avgFillTimeHours * 100).clamp(0, 100), displayValue: '${avgFillTimeHours.toStringAsFixed(1)} hrs', - barColor: const Color(0xFFF39C12), + barColor: UiColors.textWarning, met: avgFillTimeHours <= 3, close: avgFillTimeHours <= 4, ), @@ -173,17 +174,14 @@ class _PerformanceReportPageState extends State { Text( context.t.client_reports.performance_report .title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, + style: UiTypography.title1b.copyWith( color: UiColors.white, ), ), Text( context.t.client_reports.performance_report .subtitle, - style: TextStyle( - fontSize: 12, + style: UiTypography.body3r.copyWith( color: UiColors.white.withOpacity(0.7), ), ), @@ -191,49 +189,11 @@ class _PerformanceReportPageState extends State { ), ], ), - // Export -/* - GestureDetector( - onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Export coming soon'), - duration: Duration(seconds: 2), - ), - ); - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(8), - ), - child: const Row( - children: [ - Icon(UiIcons.download, - size: 14, color: UiColors.primary), - SizedBox(width: 6), - Text( - 'Export', - style: TextStyle( - color: UiColors.primary, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), -*/ ], ), ), - // ” Content ””””””””””””””””””””” + // Content Transform.translate( offset: const Offset(0, -16), child: Padding( @@ -248,7 +208,7 @@ class _PerformanceReportPageState extends State { horizontal: 20, ), decoration: BoxDecoration( - color: const Color(0xFFF0F4FF), + color: UiColors.tagInProgress, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( @@ -268,17 +228,14 @@ class _PerformanceReportPageState extends State { const SizedBox(height: 12), Text( context.t.client_reports.performance_report.overall_score.title, - style: const TextStyle( - fontSize: 13, + style: UiTypography.body3m.copyWith( color: UiColors.textSecondary, ), ), const SizedBox(height: 8), Text( '${overallScore.toStringAsFixed(0)}/100', - style: const TextStyle( - fontSize: 48, - fontWeight: FontWeight.bold, + style: UiTypography.secondaryDisplay2b.copyWith( color: UiColors.primary, ), ), @@ -294,9 +251,7 @@ class _PerformanceReportPageState extends State { ), child: Text( scoreLabel, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, + style: UiTypography.body3b.copyWith( color: scoreLabelColor, ), ), @@ -325,9 +280,7 @@ class _PerformanceReportPageState extends State { children: [ Text( context.t.client_reports.performance_report.kpis_title, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, + style: UiTypography.titleUppercase4b.copyWith( color: UiColors.textSecondary, letterSpacing: 1.2, ), @@ -357,9 +310,8 @@ class _PerformanceReportPageState extends State { } } -// ” KPI data model ”””””””””””””””””””””””””””””” +/// Data model for a single KPI row. class _KpiData { - const _KpiData({ required this.icon, required this.iconColor, @@ -371,21 +323,40 @@ class _KpiData { required this.met, required this.close, }); + + /// The KPI icon. final IconData icon; + + /// The icon tint color. final Color iconColor; + + /// The KPI label text. final String label; + + /// The target description text. final String target; - final double value; // 0-100 for bar + + /// The KPI value (0-100) for the progress bar. + final double value; + + /// The formatted display value string. final String displayValue; + + /// The progress bar color. final Color barColor; + + /// Whether the KPI target has been met. final bool met; + + /// Whether the KPI is close to the target. final bool close; } -// ” KPI row widget ”””””””””””””””””””””””””””””” +/// Widget rendering a single KPI row with label, progress bar, and badge. class _KpiRow extends StatelessWidget { - const _KpiRow({required this.kpi}); + + /// The KPI data to render. final _KpiData kpi; @override @@ -428,33 +399,24 @@ class _KpiRow extends StatelessWidget { children: [ Text( kpi.label, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: UiColors.textPrimary, - ), + style: UiTypography.body3m, ), Text( kpi.target, - style: const TextStyle( - fontSize: 11, + style: UiTypography.titleUppercase4m.copyWith( color: UiColors.textSecondary, ), ), ], ), ), - // Value + badge inline (matches prototype) + // Value + badge inline Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( kpi.displayValue, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, - ), + style: UiTypography.body1b, ), const SizedBox(width: 6), Container( @@ -468,9 +430,7 @@ class _KpiRow extends StatelessWidget { ), child: Text( badgeText, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, + style: UiTypography.footnote2b.copyWith( color: badgeColor, ), ), @@ -494,4 +454,3 @@ class _KpiRow extends StatelessWidget { ); } } - 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 fbcd3c38..de02465f 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 @@ -97,16 +97,13 @@ class _SpendReportPageState extends State { children: [ Text( context.t.client_reports.spend_report.title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, + style: UiTypography.title1b.copyWith( color: UiColors.white, ), ), Text( context.t.client_reports.spend_report.subtitle, - style: TextStyle( - fontSize: 12, + style: UiTypography.body3r.copyWith( color: UiColors.white.withOpacity(0.7), ), ), @@ -179,11 +176,7 @@ class _SpendReportPageState extends State { Text( context.t.client_reports.spend_report .chart_title, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, - ), + style: UiTypography.body2b, ), const SizedBox(height: 32), Expanded( @@ -222,6 +215,7 @@ class _SpendReportPageState extends State { class _SpendBarChart extends StatelessWidget { const _SpendBarChart({required this.chartData}); + /// The chart data points to render. final List chartData; @override @@ -245,9 +239,8 @@ class _SpendBarChart extends StatelessWidget { BarChartRodData rod, int rodIndex) { return BarTooltipItem( '\$${rod.toY.round()}', - const TextStyle( + UiTypography.body2b.copyWith( color: UiColors.white, - fontWeight: FontWeight.bold, ), ); }, @@ -269,9 +262,8 @@ class _SpendBarChart extends StatelessWidget { space: 8, child: Text( DateFormat('E').format(date), - style: const TextStyle( + style: UiTypography.titleUppercase4m.copyWith( color: UiColors.textSecondary, - fontSize: 11, ), ), ); @@ -288,9 +280,8 @@ class _SpendBarChart extends StatelessWidget { axisSide: meta.axisSide, child: Text( '\$${(value / 1000).toStringAsFixed(0)}k', - style: const TextStyle( + style: UiTypography.footnote2r.copyWith( color: UiColors.textSecondary, - fontSize: 10, ), ), ); @@ -333,6 +324,7 @@ class _SpendBarChart extends StatelessWidget { } } +/// Stat card showing a spend metric with icon, value, and pill badge. class _SpendStatCard extends StatelessWidget { const _SpendStatCard({ required this.label, @@ -342,10 +334,19 @@ class _SpendStatCard extends StatelessWidget { required this.icon, }); + /// The metric label text. final String label; + + /// The metric value text. final String value; + + /// The pill badge text. final String pillText; + + /// The theme color for the icon and pill. final Color themeColor; + + /// The icon to display. final IconData icon; @override @@ -373,10 +374,8 @@ class _SpendStatCard extends StatelessWidget { Expanded( child: Text( label, - style: const TextStyle( - fontSize: 12, + style: UiTypography.body3m.copyWith( color: UiColors.textSecondary, - fontWeight: FontWeight.w500, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -387,11 +386,7 @@ class _SpendStatCard extends StatelessWidget { const SizedBox(height: 12), Text( value, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, - ), + style: UiTypography.headline1b, ), const SizedBox(height: 12), Container( @@ -402,9 +397,7 @@ class _SpendStatCard extends StatelessWidget { ), child: Text( pillText, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, + style: UiTypography.footnote2b.copyWith( color: themeColor, ), ), @@ -419,6 +412,7 @@ class _SpendStatCard extends StatelessWidget { class _SpendByCategoryCard extends StatelessWidget { const _SpendByCategoryCard({required this.categories}); + /// The category breakdown items. final List categories; @override @@ -441,11 +435,7 @@ class _SpendByCategoryCard extends StatelessWidget { children: [ Text( context.t.client_reports.spend_report.spend_by_industry, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, - ), + style: UiTypography.body2b, ), const SizedBox(height: 24), if (categories.isEmpty) @@ -454,7 +444,9 @@ class _SpendByCategoryCard extends StatelessWidget { padding: const EdgeInsets.all(16.0), child: Text( context.t.client_reports.spend_report.no_industry_data, - style: const TextStyle(color: UiColors.textSecondary), + style: UiTypography.body2r.copyWith( + color: UiColors.textSecondary, + ), ), ), ) @@ -469,8 +461,7 @@ class _SpendByCategoryCard extends StatelessWidget { children: [ Text( item.category, - style: const TextStyle( - fontSize: 13, + style: UiTypography.body3m.copyWith( color: UiColors.textSecondary, ), ), @@ -478,11 +469,7 @@ class _SpendByCategoryCard extends StatelessWidget { NumberFormat.currency( symbol: r'$', decimalDigits: 0) .format(item.amountCents / 100), - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, - ), + style: UiTypography.body3b, ), ], ), @@ -500,8 +487,7 @@ class _SpendByCategoryCard extends StatelessWidget { Text( context.t.client_reports.spend_report.percent_total( percent: item.percentage.toStringAsFixed(1)), - style: const TextStyle( - fontSize: 10, + style: UiTypography.footnote2r.copyWith( color: UiColors.textDescription, ), ), 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 index d9c26fbb..82abcffc 100644 --- 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 @@ -15,7 +15,7 @@ class ReportDetailSkeleton extends StatelessWidget { return UiShimmer( child: SingleChildScrollView( child: Column( - children: [ + children: [ // Header area (matches the blue header with back button + title) Container( padding: const EdgeInsets.only( @@ -26,12 +26,12 @@ class ReportDetailSkeleton extends StatelessWidget { ), color: UiColors.primary, child: Row( - children: [ + children: [ const UiShimmerCircle(size: UiConstants.space10), const SizedBox(width: UiConstants.space3), Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ UiShimmerBox( width: 140, height: 18, @@ -57,10 +57,10 @@ class ReportDetailSkeleton extends StatelessWidget { const EdgeInsets.symmetric(horizontal: UiConstants.space5), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ // Summary stat cards row const Row( - children: [ + children: [ Expanded(child: UiShimmerStatsCard()), SizedBox(width: UiConstants.space3), Expanded(child: UiShimmerStatsCard()), @@ -78,14 +78,14 @@ class ReportDetailSkeleton extends StatelessWidget { ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + 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) { + children: List.generate(7, (int index) { // Varying bar heights for visual interest final double height = 40.0 + (index * 17 % 120); @@ -113,20 +113,20 @@ class ReportDetailSkeleton extends StatelessWidget { ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ const UiShimmerLine(width: 160, height: 14), const SizedBox(height: UiConstants.space6), - ...List.generate(3, (int index) { + ...List.generate(3, (int index) { return Padding( padding: const EdgeInsets.only( bottom: UiConstants.space5, ), child: Column( - children: [ + children: [ const Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + children: [ UiShimmerLine(width: 100, height: 12), UiShimmerLine(width: 60, height: 12), ], diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metric_card.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metric_card.dart index 3040f6ed..52fc9135 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metric_card.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metric_card.dart @@ -75,11 +75,7 @@ class MetricCard extends StatelessWidget { children: [ Text( value, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, - ), + style: UiTypography.headline1b, ), const SizedBox(height: 4), Container( 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 4436b5c6..dcd2ece3 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 @@ -49,8 +49,9 @@ class MetricsGrid extends StatelessWidget { Expanded( child: Text( state.message, - style: - const TextStyle(color: UiColors.error, fontSize: 12), + style: UiTypography.body3r.copyWith( + color: UiColors.error, + ), ), ), ], 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 index 61d5940d..777c4591 100644 --- 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 @@ -17,13 +17,13 @@ class MetricCardSkeleton extends StatelessWidget { ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ // Icon + label row - Row( - children: [ - const UiShimmerCircle(size: UiConstants.space6), - const SizedBox(width: UiConstants.space2), - const Expanded( + const Row( + children: [ + UiShimmerCircle(size: UiConstants.space6), + SizedBox(width: UiConstants.space2), + Expanded( child: UiShimmerLine(width: 60, height: 10), ), ], 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 index 9181ec7a..d1e7cdf2 100644 --- 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 @@ -22,7 +22,7 @@ class MetricsGridSkeleton extends StatelessWidget { mainAxisSpacing: UiConstants.space3, crossAxisSpacing: UiConstants.space3, childAspectRatio: 1.32, - children: List.generate(6, (int index) { + children: List.generate(6, (int index) { return const MetricCardSkeleton(); }), ), diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/quick_reports_section.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/quick_reports_section.dart index 5ca80eb6..200fb4f2 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/quick_reports_section.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/quick_reports_section.dart @@ -1,10 +1,8 @@ -// 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/widgets/reports_page/report_card.dart'; import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'report_card.dart'; - /// A section displaying quick access report cards. /// /// Shows 4 quick report cards for: diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/report_card.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/report_card.dart index f5c73970..9c2b1cd5 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/report_card.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/report_card.dart @@ -69,11 +69,7 @@ class ReportCard extends StatelessWidget { children: [ Text( name, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: UiColors.textPrimary, - ), + style: UiTypography.body2m, maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -88,8 +84,7 @@ class ReportCard extends StatelessWidget { const SizedBox(width: 4), Text( context.t.client_reports.quick_reports.two_click_export, - style: const TextStyle( - fontSize: 12, + style: UiTypography.body3r.copyWith( color: UiColors.textSecondary, ), ), diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/reports_header.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/reports_header.dart index 124a2c35..67c26db2 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/reports_header.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/reports_header.dart @@ -64,9 +64,7 @@ class ReportsHeader extends StatelessWidget { const SizedBox(width: 12), Text( context.t.client_reports.title, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, + style: UiTypography.headline3b.copyWith( color: UiColors.white, ), ), @@ -98,12 +96,9 @@ class ReportsHeader extends StatelessWidget { ), labelColor: UiColors.primary, unselectedLabelColor: UiColors.white, - labelStyle: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 14, - ), + labelStyle: UiTypography.body2m, indicatorSize: TabBarIndicatorSize.tab, - dividerColor: Colors.transparent, + dividerColor: UiColors.transparent, tabs: [ Tab(text: context.t.client_reports.tabs.today), Tab(text: context.t.client_reports.tabs.week), diff --git a/apps/mobile/packages/features/client/reports/lib/src/reports_module.dart b/apps/mobile/packages/features/client/reports/lib/src/reports_module.dart index 9bdc8fb6..3902f714 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/reports_module.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/reports_module.dart @@ -1,5 +1,12 @@ import 'package:client_reports/src/data/repositories_impl/reports_repository_impl.dart'; import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/domain/usecases/get_coverage_report_usecase.dart'; +import 'package:client_reports/src/domain/usecases/get_daily_ops_report_usecase.dart'; +import 'package:client_reports/src/domain/usecases/get_forecast_report_usecase.dart'; +import 'package:client_reports/src/domain/usecases/get_no_show_report_usecase.dart'; +import 'package:client_reports/src/domain/usecases/get_performance_report_usecase.dart'; +import 'package:client_reports/src/domain/usecases/get_reports_summary_usecase.dart'; +import 'package:client_reports/src/domain/usecases/get_spend_report_usecase.dart'; import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.dart'; import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_bloc.dart'; import 'package:client_reports/src/presentation/blocs/forecast/forecast_bloc.dart'; @@ -25,16 +32,84 @@ class ReportsModule extends Module { @override void binds(Injector i) { - i.addLazySingleton( + // ── Repository ─────────────────────────────────────────────────────────── + i.addLazySingleton( () => ReportsRepositoryImpl(apiService: i.get()), ); - i.add(DailyOpsBloc.new); - i.add(SpendBloc.new); - i.add(CoverageBloc.new); - i.add(ForecastBloc.new); - i.add(PerformanceBloc.new); - i.add(NoShowBloc.new); - i.add(ReportsSummaryBloc.new); + + // ── Use Cases ──────────────────────────────────────────────────────────── + i.add( + () => GetDailyOpsReportUseCase( + i.get(), + ), + ); + i.add( + () => GetSpendReportUseCase( + i.get(), + ), + ); + i.add( + () => GetCoverageReportUseCase( + i.get(), + ), + ); + i.add( + () => GetForecastReportUseCase( + i.get(), + ), + ); + i.add( + () => GetPerformanceReportUseCase( + i.get(), + ), + ); + i.add( + () => GetNoShowReportUseCase( + i.get(), + ), + ); + i.add( + () => GetReportsSummaryUseCase( + i.get(), + ), + ); + + // ── BLoCs ──────────────────────────────────────────────────────────────── + i.add( + () => DailyOpsBloc( + getDailyOpsReportUseCase: i.get(), + ), + ); + i.add( + () => SpendBloc( + getSpendReportUseCase: i.get(), + ), + ); + i.add( + () => CoverageBloc( + getCoverageReportUseCase: i.get(), + ), + ); + i.add( + () => ForecastBloc( + getForecastReportUseCase: i.get(), + ), + ); + i.add( + () => PerformanceBloc( + getPerformanceReportUseCase: i.get(), + ), + ); + i.add( + () => NoShowBloc( + getNoShowReportUseCase: i.get(), + ), + ); + i.add( + () => ReportsSummaryBloc( + getReportsSummaryUseCase: i.get(), + ), + ); } @override diff --git a/apps/mobile/packages/features/client/settings/lib/client_settings.dart b/apps/mobile/packages/features/client/settings/lib/client_settings.dart index 770d4216..37a41377 100644 --- a/apps/mobile/packages/features/client/settings/lib/client_settings.dart +++ b/apps/mobile/packages/features/client/settings/lib/client_settings.dart @@ -21,7 +21,10 @@ class ClientSettingsModule extends Module { void binds(Injector i) { // Repositories i.addLazySingleton( - () => SettingsRepositoryImpl(apiService: i.get()), + () => SettingsRepositoryImpl( + apiService: i.get(), + firebaseAuthService: i.get(), + ), ); // UseCases diff --git a/apps/mobile/packages/features/client/settings/lib/src/data/repositories_impl/settings_repository_impl.dart b/apps/mobile/packages/features/client/settings/lib/src/data/repositories_impl/settings_repository_impl.dart index 15ff9337..b2fd9182 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/data/repositories_impl/settings_repository_impl.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/data/repositories_impl/settings_repository_impl.dart @@ -1,6 +1,5 @@ import 'dart:developer' as developer; -import 'package:firebase_auth/firebase_auth.dart' as firebase; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -8,16 +7,22 @@ import 'package:client_settings/src/domain/repositories/settings_repository_inte /// Implementation of [SettingsRepositoryInterface]. /// -/// Uses V2 API for server-side token revocation and Firebase Auth for local -/// sign-out. Clears the [ClientSessionStore] on sign-out. +/// Uses V2 API for server-side token revocation and [FirebaseAuthService] +/// from core for local sign-out. Clears the [ClientSessionStore] on sign-out. class SettingsRepositoryImpl implements SettingsRepositoryInterface { - /// Creates a [SettingsRepositoryImpl] with the required [BaseApiService]. - const SettingsRepositoryImpl({required BaseApiService apiService}) - : _apiService = apiService; + /// Creates a [SettingsRepositoryImpl] with the required dependencies. + const SettingsRepositoryImpl({ + required BaseApiService apiService, + required FirebaseAuthService firebaseAuthService, + }) : _apiService = apiService, + _firebaseAuthService = firebaseAuthService; /// The V2 API service for backend calls. final BaseApiService _apiService; + /// Core Firebase Auth service for local sign-out. + final FirebaseAuthService _firebaseAuthService; + @override Future signOut() async { try { @@ -31,8 +36,8 @@ class SettingsRepositoryImpl implements SettingsRepositoryInterface { // Continue with local sign-out even if server-side fails. } - // Step 2: Sign out from local Firebase Auth. - await firebase.FirebaseAuth.instance.signOut(); + // Step 2: Sign out from local Firebase Auth via core service. + await _firebaseAuthService.signOut(); // Step 3: Clear the client session store. ClientSessionStore.instance.clear(); diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/edit_profile_page.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/edit_profile_page.dart index a73d6847..ca0f5845 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/edit_profile_page.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/edit_profile_page.dart @@ -10,7 +10,7 @@ class EditProfilePage extends StatefulWidget { } class _EditProfilePageState extends State { - final _formKey = GlobalKey(); + final GlobalKey _formKey = GlobalKey(); late TextEditingController _firstNameController; late TextEditingController _lastNameController; late TextEditingController _emailController; @@ -50,14 +50,18 @@ class _EditProfilePageState extends State { key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ Center( child: Stack( - children: [ - CircleAvatar( + children: [ + const CircleAvatar( radius: 50, backgroundColor: UiColors.bgSecondary, - child: const Icon(UiIcons.user, size: 40, color: UiColors.primary), + child: Icon( + UiIcons.user, + size: 40, + color: UiColors.primary, + ), ), Positioned( bottom: 0, diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart index 5332fc57..f27cb2db 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart @@ -67,7 +67,7 @@ class SettingsActions extends StatelessWidget { style: UiTypography.headline3m.textPrimary, ), content: Text( - 'Are you sure you want to log out?', + t.client_settings.profile.log_out_confirmation, style: UiTypography.body2r.textSecondary, ), actions: [ @@ -77,7 +77,7 @@ class SettingsActions extends StatelessWidget { ), UiButton.secondary( text: t.common.cancel, - onPressed: () => Modular.to.pop(), + onPressed: () => Modular.to.popSafe(), ), ], ), @@ -180,7 +180,7 @@ class _NotificationsSettingsCard extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder( - builder: (context, state) { + builder: (BuildContext context, ClientSettingsState state) { return Card( elevation: 0, shape: RoundedRectangleBorder( @@ -202,7 +202,7 @@ class _NotificationsSettingsCard extends StatelessWidget { icon: UiIcons.bell, title: context.t.client_settings.preferences.push, value: state.pushEnabled, - onChanged: (val) => + onChanged: (bool val) => ReadContext(context).read().add( ClientSettingsNotificationToggled( type: 'push', @@ -214,7 +214,7 @@ class _NotificationsSettingsCard extends StatelessWidget { icon: UiIcons.mail, title: context.t.client_settings.preferences.email, value: state.emailEnabled, - onChanged: (val) => + onChanged: (bool val) => ReadContext(context).read().add( ClientSettingsNotificationToggled( type: 'email', @@ -226,7 +226,7 @@ class _NotificationsSettingsCard extends StatelessWidget { icon: UiIcons.phone, title: context.t.client_settings.preferences.sms, value: state.smsEnabled, - onChanged: (val) => + onChanged: (bool val) => ReadContext(context).read().add( ClientSettingsNotificationToggled( type: 'sms', @@ -244,10 +244,6 @@ class _NotificationsSettingsCard extends StatelessWidget { } class _NotificationToggle extends StatelessWidget { - final IconData icon; - final String title; - final bool value; - final ValueChanged onChanged; const _NotificationToggle({ required this.icon, @@ -255,14 +251,18 @@ class _NotificationToggle extends StatelessWidget { required this.value, required this.onChanged, }); + final IconData icon; + final String title; + final bool value; + final ValueChanged onChanged; @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + children: [ Row( - children: [ + children: [ Icon(icon, size: 20, color: UiColors.iconSecondary), const SizedBox(width: UiConstants.space3), Text(title, style: UiTypography.footnote1m.textPrimary), diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart index 648a1acc..2363fdc9 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.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:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import '../../blocs/client_settings_bloc.dart'; @@ -78,7 +79,7 @@ class SettingsLogout extends StatelessWidget { // Cancel button UiButton.secondary( text: t.common.cancel, - onPressed: () => Modular.to.pop(), + onPressed: () => Modular.to.popSafe(), ), ], ), diff --git a/apps/mobile/packages/features/client/settings/pubspec.yaml b/apps/mobile/packages/features/client/settings/pubspec.yaml index c052e2ee..522463f6 100644 --- a/apps/mobile/packages/features/client/settings/pubspec.yaml +++ b/apps/mobile/packages/features/client/settings/pubspec.yaml @@ -14,8 +14,7 @@ dependencies: flutter_bloc: ^8.1.0 flutter_modular: ^6.3.0 equatable: ^2.0.5 - firebase_auth: ^6.1.2 - + # Architecture Packages design_system: path: ../../../design_system diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index b3c92f14..77737c2a 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:firebase_auth/firebase_auth.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart' as domain; import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart'; @@ -9,55 +8,42 @@ import 'package:staff_authentication/src/utils/test_phone_numbers.dart'; /// V2 API implementation of [AuthRepositoryInterface]. /// -/// Uses the Firebase Auth SDK for client-side phone verification, +/// Uses [FirebaseAuthService] from core for client-side phone verification, /// then calls the V2 unified API to hydrate the session context. -/// All Data Connect dependencies have been removed. +/// All direct `firebase_auth` imports have been removed in favour of the +/// core abstraction. class AuthRepositoryImpl implements AuthRepositoryInterface { /// Creates an [AuthRepositoryImpl]. /// - /// Requires a [domain.BaseApiService] for V2 API calls. - AuthRepositoryImpl({required domain.BaseApiService apiService}) - : _apiService = apiService; + /// Requires a [domain.BaseApiService] for V2 API calls and a + /// [FirebaseAuthService] for client-side Firebase Auth operations. + AuthRepositoryImpl({ + required domain.BaseApiService apiService, + required FirebaseAuthService firebaseAuthService, + }) : _apiService = apiService, + _firebaseAuthService = firebaseAuthService; /// The V2 API service for backend calls. final domain.BaseApiService _apiService; - /// Firebase Auth instance for client-side phone verification. - final FirebaseAuth _auth = FirebaseAuth.instance; - - /// Completer for the pending phone verification request. - Completer? _pendingVerification; + /// Core Firebase Auth service abstraction. + final FirebaseAuthService _firebaseAuthService; @override - Stream get currentUser => - _auth.authStateChanges().map((User? firebaseUser) { - if (firebaseUser == null) { - return null; - } - - return domain.User( - id: firebaseUser.uid, - email: firebaseUser.email, - displayName: firebaseUser.displayName, - phone: firebaseUser.phoneNumber, - status: domain.UserStatus.active, - ); - }); + Stream get currentUser => _firebaseAuthService.authStateChanges; /// Initiates phone verification via the V2 API. /// /// Calls `POST /auth/staff/phone/start` first. The server decides the /// verification mode: - /// - `CLIENT_FIREBASE_SDK` — mobile must do Firebase phone auth client-side - /// - `IDENTITY_TOOLKIT_SMS` — server sent the SMS, returns `sessionInfo` + /// - `CLIENT_FIREBASE_SDK` -- mobile must do Firebase phone auth client-side + /// - `IDENTITY_TOOLKIT_SMS` -- server sent the SMS, returns `sessionInfo` /// /// For mobile without recaptcha tokens, the server returns /// `CLIENT_FIREBASE_SDK` and we fall back to the Firebase Auth SDK. @override Future signInWithPhone({required String phoneNumber}) async { // Step 1: Try V2 to let the server decide the auth mode. - // Falls back to CLIENT_FIREBASE_SDK if the API call fails (e.g. server - // down, 500, or non-JSON response). String mode = 'CLIENT_FIREBASE_SDK'; String? sessionInfo; @@ -74,7 +60,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { mode = startData['mode'] as String? ?? 'CLIENT_FIREBASE_SDK'; sessionInfo = startData['sessionInfo'] as String?; } catch (_) { - // V2 start call failed — fall back to client-side Firebase SDK. + // V2 start call failed -- fall back to client-side Firebase SDK. } // Step 2: If server sent the SMS, return the sessionInfo for verify step. @@ -82,55 +68,16 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { return sessionInfo; } - // Step 3: CLIENT_FIREBASE_SDK mode — do Firebase phone auth client-side. - final Completer completer = Completer(); - _pendingVerification = completer; - - await _auth.verifyPhoneNumber( + // Step 3: CLIENT_FIREBASE_SDK mode -- do Firebase phone auth client-side. + return _firebaseAuthService.verifyPhoneNumber( phoneNumber: phoneNumber, - verificationCompleted: (PhoneAuthCredential credential) { - if (TestPhoneNumbers.isTestNumber(phoneNumber)) return; - }, - verificationFailed: (FirebaseAuthException e) { - if (!completer.isCompleted) { - if (e.code == 'network-request-failed' || - e.message?.contains('Unable to resolve host') == true) { - completer.completeError( - const domain.NetworkException( - technicalMessage: 'Auth network failure', - ), - ); - } else { - completer.completeError( - domain.SignInFailedException( - technicalMessage: 'Firebase ${e.code}: ${e.message}', - ), - ); - } - } - }, - codeSent: (String verificationId, _) { - if (!completer.isCompleted) { - completer.complete(verificationId); - } - }, - codeAutoRetrievalTimeout: (String verificationId) { - if (!completer.isCompleted) { - completer.complete(verificationId); - } - }, + onAutoVerified: TestPhoneNumbers.isTestNumber(phoneNumber) ? null : null, ); - - return completer.future; } @override void cancelPendingPhoneVerification() { - final Completer? completer = _pendingVerification; - if (completer != null && !completer.isCompleted) { - completer.completeError(Exception('Phone verification cancelled.')); - } - _pendingVerification = null; + _firebaseAuthService.cancelPendingPhoneVerification(); } /// Verifies the OTP and completes authentication via the V2 API. @@ -145,53 +92,26 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { required String smsCode, required AuthMode mode, }) async { - // Step 1: Sign in with Firebase credential (client-side). - final PhoneAuthCredential credential = PhoneAuthProvider.credential( + // Step 1: Sign in with Firebase credential via core service. + final PhoneSignInResult signInResult = + await _firebaseAuthService.signInWithPhoneCredential( verificationId: verificationId, smsCode: smsCode, ); - final UserCredential userCredential; - try { - userCredential = await _auth.signInWithCredential(credential); - } on FirebaseAuthException catch (e) { - if (e.code == 'invalid-verification-code') { - throw const domain.InvalidCredentialsException( - technicalMessage: 'Invalid OTP code entered.', - ); - } - rethrow; - } - - final User? firebaseUser = userCredential.user; - if (firebaseUser == null) { - throw const domain.SignInFailedException( - technicalMessage: - 'Phone verification failed, no Firebase user received.', - ); - } - - // Step 2: Get the Firebase ID token. - final String? idToken = await firebaseUser.getIdToken(); - if (idToken == null) { - throw const domain.SignInFailedException( - technicalMessage: 'Failed to obtain Firebase ID token.', - ); - } - - // Step 3: Call V2 verify endpoint with the Firebase ID token. + // Step 2: Call V2 verify endpoint with the Firebase ID token. final String v2Mode = mode == AuthMode.signup ? 'sign-up' : 'sign-in'; final domain.ApiResponse response = await _apiService.post( AuthEndpoints.staffPhoneVerify, data: { - 'idToken': idToken, + 'idToken': signInResult.idToken, 'mode': v2Mode, }, ); final Map data = response.data as Map; - // Step 4: Check for business logic errors from the V2 API. + // Step 3: Check for business logic errors from the V2 API. final Map? staffData = data['staff'] as Map?; final Map? userData = @@ -202,7 +122,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // - Sign-in: staff must exist if (mode == AuthMode.login) { if (staffData == null) { - await _auth.signOut(); + await _firebaseAuthService.signOut(); throw const domain.UserNotFoundException( technicalMessage: 'Your account is not registered yet. Please register first.', @@ -210,7 +130,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { } } - // Step 5: Populate StaffSessionStore from the V2 auth envelope. + // Step 4: Populate StaffSessionStore from the V2 auth envelope. if (staffData != null) { final domain.StaffSession staffSession = domain.StaffSession.fromJson(data); @@ -219,10 +139,10 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // Build the domain user from the V2 response. final domain.User domainUser = domain.User( - id: userData?['id'] as String? ?? firebaseUser.uid, + id: userData?['id'] as String? ?? signInResult.uid, email: userData?['email'] as String?, displayName: userData?['displayName'] as String?, - phone: userData?['phone'] as String? ?? firebaseUser.phoneNumber, + phone: userData?['phone'] as String? ?? signInResult.phoneNumber, status: domain.UserStatus.active, ); @@ -238,7 +158,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // Sign-out should not fail even if the API call fails. // The local sign-out below will clear the session regardless. } - await _auth.signOut(); + await _firebaseAuthService.signOut(); StaffSessionStore.instance.clear(); } } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/profile_setup_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/profile_setup_repository_impl.dart index 64a45151..5ad7ac36 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/profile_setup_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/profile_setup_repository_impl.dart @@ -1,4 +1,3 @@ -import 'package:firebase_auth/firebase_auth.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -11,13 +10,20 @@ import 'package:staff_authentication/src/domain/repositories/profile_setup_repos class ProfileSetupRepositoryImpl implements ProfileSetupRepository { /// Creates a [ProfileSetupRepositoryImpl]. /// - /// Requires a [BaseApiService] for V2 API calls. - ProfileSetupRepositoryImpl({required BaseApiService apiService}) - : _apiService = apiService; + /// Requires a [BaseApiService] for V2 API calls and a + /// [FirebaseAuthService] to resolve the current user's phone number. + ProfileSetupRepositoryImpl({ + required BaseApiService apiService, + required FirebaseAuthService firebaseAuthService, + }) : _apiService = apiService, + _firebaseAuthService = firebaseAuthService; /// The V2 API service for backend calls. final BaseApiService _apiService; + /// Core Firebase Auth service for querying current user info. + final FirebaseAuthService _firebaseAuthService; + @override Future submitProfile({ required String fullName, @@ -38,7 +44,7 @@ class ProfileSetupRepositoryImpl implements ProfileSetupRepository { // to the Firebase Auth current user's phone if the caller passed empty. final String resolvedPhone = phoneNumber.isNotEmpty ? phoneNumber - : (FirebaseAuth.instance.currentUser?.phoneNumber ?? ''); + : (_firebaseAuthService.currentUserPhoneNumber ?? ''); final ApiResponse response = await _apiService.post( StaffEndpoints.profileSetup, diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input.dart index 7eb7b850..28414a34 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input.dart @@ -36,7 +36,7 @@ class _PhoneInputState extends State { if (!mounted) return; _currentPhone = value; - final AuthBloc bloc = context.read(); + final AuthBloc bloc = ReadContext(context).read(); if (!bloc.isClosed) { bloc.add(AuthPhoneUpdated(value)); } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_location.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_location.dart index c6f4c5e2..f4f989a7 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_location.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_location.dart @@ -48,7 +48,7 @@ class _ProfileSetupLocationState extends State { void _onSearchChanged(String query) { if (_debounce?.isActive ?? false) _debounce!.cancel(); _debounce = Timer(const Duration(milliseconds: 300), () { - context.read().add( + ReadContext(context).read().add( ProfileSetupLocationQueryChanged(query), ); }); @@ -62,7 +62,7 @@ class _ProfileSetupLocationState extends State { )..add(location); widget.onLocationsChanged(updatedList); _locationController.clear(); - context.read().add( + ReadContext(context).read().add( const ProfileSetupClearLocationSuggestions(), ); } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart b/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart index cfaf7c81..fe602ef6 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart @@ -32,10 +32,16 @@ class StaffAuthenticationModule extends Module { void binds(Injector i) { // Repositories i.addLazySingleton( - () => AuthRepositoryImpl(apiService: i.get()), + () => AuthRepositoryImpl( + apiService: i.get(), + firebaseAuthService: i.get(), + ), ); i.addLazySingleton( - () => ProfileSetupRepositoryImpl(apiService: i.get()), + () => ProfileSetupRepositoryImpl( + apiService: i.get(), + firebaseAuthService: i.get(), + ), ); i.addLazySingleton(PlaceRepositoryImpl.new); diff --git a/apps/mobile/packages/features/staff/authentication/pubspec.yaml b/apps/mobile/packages/features/staff/authentication/pubspec.yaml index eccb41d4..2c80bcd3 100644 --- a/apps/mobile/packages/features/staff/authentication/pubspec.yaml +++ b/apps/mobile/packages/features/staff/authentication/pubspec.yaml @@ -14,7 +14,6 @@ dependencies: flutter_bloc: ^8.1.0 flutter_modular: ^6.3.0 equatable: ^2.0.5 - firebase_auth: ^6.1.2 http: ^1.2.0 pinput: ^5.0.0 smart_auth: ^1.1.0 @@ -28,6 +27,7 @@ dependencies: path: ../../../design_system core_localization: path: ../../../core_localization + bloc: ^8.1.4 dev_dependencies: 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 cd82a9cf..d219f36c 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 @@ -201,7 +201,7 @@ class _AvailabilityPageState extends State { height: 32, child: OutlinedButton( onPressed: () => - context.read().add(PerformQuickSet(type)), + ReadContext(context).read().add(PerformQuickSet(type)), style: OutlinedButton.styleFrom( padding: EdgeInsets.zero, side: BorderSide( @@ -252,14 +252,14 @@ class _AvailabilityPageState extends State { children: [ _buildNavButton( UiIcons.chevronLeft, - () => context.read().add( + () => ReadContext(context).read().add( const NavigateWeek(-1), ), ), Text(monthYear, style: UiTypography.title2b), _buildNavButton( UiIcons.chevronRight, - () => context.read().add( + () => ReadContext(context).read().add( const NavigateWeek(1), ), ), @@ -307,7 +307,7 @@ class _AvailabilityPageState extends State { return Expanded( child: GestureDetector( onTap: () => - context.read().add(SelectDate(dayDate)), + ReadContext(context).read().add(SelectDate(dayDate)), child: Container( margin: const EdgeInsets.symmetric(horizontal: 2), padding: const EdgeInsets.symmetric(vertical: UiConstants.space3), diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart index 3006a1ed..8b903f81 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart @@ -1,6 +1,8 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_clock_in/src/domain/arguments/clock_in_arguments.dart'; +import 'package:staff_clock_in/src/domain/arguments/clock_out_arguments.dart'; import 'package:staff_clock_in/src/domain/repositories/clock_in_repository_interface.dart'; /// Implementation of [ClockInRepositoryInterface] using the V2 REST API. @@ -19,11 +21,12 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface { final ApiResponse response = await _apiService.get( StaffEndpoints.clockInShiftsToday, ); - final List items = response.data['items'] as List; + final List items = + response.data['items'] as List? ?? []; return items .map( (dynamic json) => - _mapTodayShiftJsonToShift(json as Map), + Shift.fromJson(json as Map), ) .toList(); } @@ -37,55 +40,22 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface { } @override - Future clockIn({ - required String shiftId, - String? notes, - }) async { + Future clockIn(ClockInArguments arguments) async { await _apiService.post( StaffEndpoints.clockIn, - data: { - 'shiftId': shiftId, - 'sourceType': 'GEO', - if (notes != null && notes.isNotEmpty) 'notes': notes, - }, + data: arguments.toJson(), ); // Re-fetch the attendance status to get the canonical state after clock-in. return getAttendanceStatus(); } @override - Future clockOut({ - String? notes, - int? breakTimeMinutes, - String? shiftId, - }) async { + Future clockOut(ClockOutArguments arguments) async { await _apiService.post( StaffEndpoints.clockOut, - data: { - if (shiftId != null) 'shiftId': shiftId, - 'sourceType': 'GEO', - if (notes != null && notes.isNotEmpty) 'notes': notes, - if (breakTimeMinutes != null) 'breakMinutes': breakTimeMinutes, - }, + data: arguments.toJson(), ); // Re-fetch the attendance status to get the canonical state after clock-out. return getAttendanceStatus(); } - - /// Maps a V2 `listTodayShifts` JSON item to the domain [Shift] entity. - static Shift _mapTodayShiftJsonToShift(Map json) { - return Shift( - id: json['shiftId'] as String, - orderId: json['orderId'] as String? ?? '', - title: json['clientName'] as String? ?? json['roleName'] as String? ?? '', - status: ShiftStatus.assigned, - startsAt: DateTime.parse(json['startTime'] as String), - endsAt: DateTime.parse(json['endTime'] as String), - locationName: json['location'] as String?, - latitude: Shift.parseDouble(json['latitude']), - longitude: Shift.parseDouble(json['longitude']), - requiredWorkers: 0, - assignedWorkers: 0, - ); - } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart index d22ea458..97e7a6ab 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart @@ -1,7 +1,50 @@ // ignore_for_file: avoid_print +// Print statements are intentional — background isolates cannot use +// dart:developer or structured loggers from the DI container. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; +/// Lightweight HTTP client for background isolate API calls. +/// +/// Cannot use Dio or DI — uses [HttpClient] directly with auth tokens +/// from [StorageService] (SharedPreferences, works across isolates). +class BackgroundApiClient { + /// Creates a [BackgroundApiClient] with its own HTTP client and storage. + BackgroundApiClient() : _client = HttpClient(), _storage = StorageService(); + + final HttpClient _client; + final StorageService _storage; + + /// POSTs JSON to [path] under the V2 API base URL. + /// + /// Returns the HTTP status code, or null if no auth token is available. + Future post(String path, Map body) async { + final String? token = await _storage.getString( + BackgroundGeofenceService._keyAuthToken, + ); + if (token == null || token.isEmpty) { + print('[BackgroundApiClient] No auth token stored, skipping POST'); + return null; + } + + final Uri uri = Uri.parse('${AppConfig.v2ApiBaseUrl}$path'); + final HttpClientRequest request = await _client.postUrl(uri); + request.headers.set(HttpHeaders.contentTypeHeader, 'application/json'); + request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $token'); + request.write(jsonEncode(body)); + final HttpClientResponse response = await request.close(); + await response.drain(); + return response.statusCode; + } + + /// Closes the underlying [HttpClient]. + void dispose() => _client.close(force: false); +} + /// Top-level callback dispatcher for background geofence tasks. /// /// Must be a top-level function because workmanager executes it in a separate @@ -13,83 +56,134 @@ import 'package:krow_domain/krow_domain.dart'; /// is retained solely for this entry-point pattern. @pragma('vm:entry-point') void backgroundGeofenceDispatcher() { - const BackgroundTaskService().executeTask( - (String task, Map? inputData) async { - print('[BackgroundGeofence] Task triggered: $task'); - print('[BackgroundGeofence] Input data: $inputData'); - print( - '[BackgroundGeofence] Timestamp: ${DateTime.now().toIso8601String()}', - ); + const BackgroundTaskService().executeTask(( + String task, + Map? inputData, + ) async { + print('[BackgroundGeofence] Task triggered: $task'); + print('[BackgroundGeofence] Input data: $inputData'); + print( + '[BackgroundGeofence] Timestamp: ${DateTime.now().toIso8601String()}', + ); - final double? targetLat = inputData?['targetLat'] as double?; - final double? targetLng = inputData?['targetLng'] as double?; - final String? shiftId = inputData?['shiftId'] as String?; + final double? targetLat = inputData?['targetLat'] as double?; + final double? targetLng = inputData?['targetLng'] as double?; + final String? shiftId = inputData?['shiftId'] as String?; + final double geofenceRadius = + (inputData?['geofenceRadiusMeters'] as num?)?.toDouble() ?? + BackgroundGeofenceService.defaultGeofenceRadiusMeters; - print( - '[BackgroundGeofence] Target: lat=$targetLat, lng=$targetLng, ' - 'shiftId=$shiftId', - ); + print( + '[BackgroundGeofence] Target: lat=$targetLat, lng=$targetLng, ' + 'shiftId=$shiftId, geofenceRadius=${geofenceRadius.round()}m', + ); - if (targetLat == null || targetLng == null) { - print( - '[BackgroundGeofence] Missing target coordinates, skipping check', - ); - return true; - } - - try { - const LocationService locationService = LocationService(); - final DeviceLocation location = await locationService.getCurrentLocation(); - print( - '[BackgroundGeofence] Current position: ' - 'lat=${location.latitude}, lng=${location.longitude}', - ); - - final double distance = calculateDistance( - location.latitude, - location.longitude, - targetLat, - targetLng, - ); - print( - '[BackgroundGeofence] Distance from target: ${distance.round()}m', - ); - - if (distance > BackgroundGeofenceService.geofenceRadiusMeters) { - print( - '[BackgroundGeofence] Worker is outside geofence ' - '(${distance.round()}m > ' - '${BackgroundGeofenceService.geofenceRadiusMeters.round()}m), ' - 'showing notification', - ); - - final String title = inputData?['leftGeofenceTitle'] as String? ?? - "You've Left the Workplace"; - final String body = inputData?['leftGeofenceBody'] as String? ?? - 'You appear to be more than 500m from your shift location.'; - - final NotificationService notificationService = - NotificationService(); - await notificationService.showNotification( - id: BackgroundGeofenceService.leftGeofenceNotificationId, - title: title, - body: body, - ); - } else { - print( - '[BackgroundGeofence] Worker is within geofence ' - '(${distance.round()}m <= ' - '${BackgroundGeofenceService.geofenceRadiusMeters.round()}m)', - ); - } - } catch (e) { - print('[BackgroundGeofence] Error during background check: $e'); - } - - print('[BackgroundGeofence] Background check completed'); + if (targetLat == null || targetLng == null) { + print('[BackgroundGeofence] Missing target coordinates, skipping check'); return true; - }, - ); + } + + final BackgroundApiClient client = BackgroundApiClient(); + try { + const LocationService locationService = LocationService(); + final DeviceLocation location = await locationService + .getCurrentLocation(); + print( + '[BackgroundGeofence] Current position: ' + 'lat=${location.latitude}, lng=${location.longitude}', + ); + + final double distance = calculateDistance( + location.latitude, + location.longitude, + targetLat, + targetLng, + ); + print('[BackgroundGeofence] Distance from target: ${distance.round()}m'); + + // POST location stream to the V2 API before geofence check. + unawaited( + _postLocationStream( + client: client, + shiftId: shiftId, + location: location, + ), + ); + + if (distance > geofenceRadius) { + print( + '[BackgroundGeofence] Worker is outside geofence ' + '(${distance.round()}m > ' + '${geofenceRadius.round()}m), ' + 'showing notification', + ); + + // Fallback for when localized strings are not available in the + // background isolate. The primary path passes localized strings + // via inputData from the UI layer. + final String title = + inputData?['leftGeofenceTitle'] as String? ?? + 'You have left the work area'; + final String body = + inputData?['leftGeofenceBody'] as String? ?? + 'You appear to have moved outside your shift location.'; + + final NotificationService notificationService = NotificationService(); + await notificationService.showNotification( + id: BackgroundGeofenceService.leftGeofenceNotificationId, + title: title, + body: body, + ); + } else { + print( + '[BackgroundGeofence] Worker is within geofence ' + '(${distance.round()}m <= ' + '${geofenceRadius.round()}m)', + ); + } + } catch (e) { + print('[BackgroundGeofence] Error during background check: $e'); + } finally { + client.dispose(); + } + + print('[BackgroundGeofence] Background check completed'); + return true; + }); +} + +/// Posts a location data point to the V2 location-streams endpoint. +/// +/// Uses [BackgroundApiClient] for isolate-safe HTTP access. +/// Failures are silently caught — location streaming is best-effort. +Future _postLocationStream({ + required BackgroundApiClient client, + required String? shiftId, + required DeviceLocation location, +}) async { + if (shiftId == null) return; + + try { + final int? status = await client.post( + StaffEndpoints.locationStreams.path, + { + 'shiftId': shiftId, + 'sourceType': 'GEO', + 'points': >[ + { + 'capturedAt': location.timestamp.toUtc().toIso8601String(), + 'latitude': location.latitude, + 'longitude': location.longitude, + 'accuracyMeters': location.accuracy.round(), + }, + ], + 'metadata': {'source': 'background-workmanager'}, + }, + ); + print('[BackgroundGeofence] Location stream POST status: $status'); + } catch (e) { + print('[BackgroundGeofence] Location stream POST failed: $e'); + } } /// Service that manages periodic background geofence checks while clocked in. @@ -98,13 +192,12 @@ void backgroundGeofenceDispatcher() { /// delivery is handled by [ClockInNotificationService]. The background isolate /// logic lives in the top-level [backgroundGeofenceDispatcher] function above. class BackgroundGeofenceService { - /// Creates a [BackgroundGeofenceService] instance. BackgroundGeofenceService({ required BackgroundTaskService backgroundTaskService, required StorageService storageService, - }) : _backgroundTaskService = backgroundTaskService, - _storageService = storageService; + }) : _backgroundTaskService = backgroundTaskService, + _storageService = storageService; /// The core background task service for scheduling periodic work. final BackgroundTaskService _backgroundTaskService; @@ -124,6 +217,9 @@ class BackgroundGeofenceService { /// Storage key for the active tracking flag. static const String _keyTrackingActive = 'geofence_tracking_active'; + /// Storage key for the Firebase auth token used in background isolate. + static const String _keyAuthToken = 'geofence_auth_token'; + /// Unique task name for the periodic background check. static const String taskUniqueName = 'geofence_background_check'; @@ -136,8 +232,12 @@ class BackgroundGeofenceService { /// it directly (background isolate has no DI access). static const int leftGeofenceNotificationId = 2; - /// Geofence radius in meters. - static const double geofenceRadiusMeters = 500; + /// Default geofence radius in meters, used as fallback when no per-shift + /// radius is provided. + static const double defaultGeofenceRadiusMeters = 500; + + /// Storage key for the per-shift geofence radius. + static const String _keyGeofenceRadius = 'geofence_radius_meters'; /// Starts periodic 15-minute background geofence checks. /// @@ -150,12 +250,17 @@ class BackgroundGeofenceService { required String shiftId, required String leftGeofenceTitle, required String leftGeofenceBody, + double geofenceRadiusMeters = defaultGeofenceRadiusMeters, + String? authToken, }) async { await Future.wait(>[ _storageService.setDouble(_keyTargetLat, targetLat), _storageService.setDouble(_keyTargetLng, targetLng), _storageService.setString(_keyShiftId, shiftId), + _storageService.setDouble(_keyGeofenceRadius, geofenceRadiusMeters), _storageService.setBool(_keyTrackingActive, true), + if (authToken != null) + _storageService.setString(_keyAuthToken, authToken), ]); await _backgroundTaskService.registerPeriodicTask( @@ -166,6 +271,7 @@ class BackgroundGeofenceService { 'targetLat': targetLat, 'targetLng': targetLng, 'shiftId': shiftId, + 'geofenceRadiusMeters': geofenceRadiusMeters, 'leftGeofenceTitle': leftGeofenceTitle, 'leftGeofenceBody': leftGeofenceBody, }, @@ -182,10 +288,20 @@ class BackgroundGeofenceService { _storageService.remove(_keyTargetLat), _storageService.remove(_keyTargetLng), _storageService.remove(_keyShiftId), + _storageService.remove(_keyGeofenceRadius), + _storageService.remove(_keyAuthToken), _storageService.setBool(_keyTrackingActive, false), ]); } + /// Stores a fresh auth token for background isolate API calls. + /// + /// Called by the foreground [GeofenceBloc] both initially and + /// periodically (~45 min) to keep the token fresh across long shifts. + Future storeAuthToken(String token) async { + await _storageService.setString(_keyAuthToken, token); + } + /// Whether background tracking is currently active. Future get isTrackingActive async { final bool? active = await _storageService.getBool(_keyTrackingActive); diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_in_arguments.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_in_arguments.dart index e2b3724f..6704f522 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_in_arguments.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_in_arguments.dart @@ -7,13 +7,99 @@ class ClockInArguments extends UseCaseArgument { const ClockInArguments({ required this.shiftId, this.notes, + this.deviceId, + this.latitude, + this.longitude, + this.accuracyMeters, + this.capturedAt, + this.overrideReason, + this.nfcTagId, + this.proofNonce, + this.proofTimestamp, + this.attestationProvider, + this.attestationToken, }); + /// The ID of the shift to clock in to. final String shiftId; /// Optional notes provided by the user during clock-in. final String? notes; + /// Device identifier for audit trail. + final String? deviceId; + + /// Latitude of the device at clock-in time. + final double? latitude; + + /// Longitude of the device at clock-in time. + final double? longitude; + + /// Horizontal accuracy of the GPS fix in meters. + final double? accuracyMeters; + + /// Timestamp when the location was captured on-device. + final DateTime? capturedAt; + + /// Justification when the worker overrides a geofence check. + final String? overrideReason; + + /// NFC tag identifier when clocking in via NFC tap. + final String? nfcTagId; + + /// Server-generated nonce for proof-of-presence validation. + final String? proofNonce; + + /// Device-local timestamp when the proof was captured. + final DateTime? proofTimestamp; + + /// Name of the attestation provider (e.g. `'apple'`, `'android'`). + final String? attestationProvider; + + /// Signed attestation token from the device integrity API. + final String? attestationToken; + + /// Serializes the arguments to a JSON map for the V2 API request body. + /// + /// Only includes non-null fields. The `sourceType` is inferred from + /// whether [nfcTagId] is present. + Map toJson() { + return { + 'shiftId': shiftId, + 'sourceType': nfcTagId != null ? 'NFC' : 'GEO', + if (notes != null && notes!.isNotEmpty) 'notes': notes, + if (deviceId != null) 'deviceId': deviceId, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + if (accuracyMeters != null) 'accuracyMeters': accuracyMeters!.round(), + if (capturedAt != null) + 'capturedAt': capturedAt!.toUtc().toIso8601String(), + if (overrideReason != null && overrideReason!.isNotEmpty) + 'overrideReason': overrideReason, + if (nfcTagId != null) 'nfcTagId': nfcTagId, + if (proofNonce != null) 'proofNonce': proofNonce, + if (proofTimestamp != null) + 'proofTimestamp': proofTimestamp!.toUtc().toIso8601String(), + if (attestationProvider != null) + 'attestationProvider': attestationProvider, + if (attestationToken != null) 'attestationToken': attestationToken, + }; + } + @override - List get props => [shiftId, notes]; + List get props => [ + shiftId, + notes, + deviceId, + latitude, + longitude, + accuracyMeters, + capturedAt, + overrideReason, + nfcTagId, + proofNonce, + proofTimestamp, + attestationProvider, + attestationToken, + ]; } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_out_arguments.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_out_arguments.dart index 58902f0e..f562cb6c 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_out_arguments.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_out_arguments.dart @@ -7,6 +7,17 @@ class ClockOutArguments extends UseCaseArgument { this.notes, this.breakTimeMinutes, this.shiftId, + this.deviceId, + this.latitude, + this.longitude, + this.accuracyMeters, + this.capturedAt, + this.overrideReason, + this.nfcTagId, + this.proofNonce, + this.proofTimestamp, + this.attestationProvider, + this.attestationToken, }); /// Optional notes provided by the user during clock-out. @@ -18,6 +29,82 @@ class ClockOutArguments extends UseCaseArgument { /// The shift id used by the V2 API to resolve the assignment. final String? shiftId; + /// Device identifier for audit trail. + final String? deviceId; + + /// Latitude of the device at clock-out time. + final double? latitude; + + /// Longitude of the device at clock-out time. + final double? longitude; + + /// Horizontal accuracy of the GPS fix in meters. + final double? accuracyMeters; + + /// Timestamp when the location was captured on-device. + final DateTime? capturedAt; + + /// Justification when the worker overrides a geofence check. + final String? overrideReason; + + /// NFC tag identifier when clocking out via NFC tap. + final String? nfcTagId; + + /// Server-generated nonce for proof-of-presence validation. + final String? proofNonce; + + /// Device-local timestamp when the proof was captured. + final DateTime? proofTimestamp; + + /// Name of the attestation provider (e.g. `'apple'`, `'android'`). + final String? attestationProvider; + + /// Signed attestation token from the device integrity API. + final String? attestationToken; + + /// Serializes the arguments to a JSON map for the V2 API request body. + /// + /// Only includes non-null fields. The `sourceType` is inferred from + /// whether [nfcTagId] is present. + Map toJson() { + return { + if (shiftId != null) 'shiftId': shiftId, + 'sourceType': nfcTagId != null ? 'NFC' : 'GEO', + if (notes != null && notes!.isNotEmpty) 'notes': notes, + if (breakTimeMinutes != null) 'breakMinutes': breakTimeMinutes, + if (deviceId != null) 'deviceId': deviceId, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + if (accuracyMeters != null) 'accuracyMeters': accuracyMeters!.round(), + if (capturedAt != null) + 'capturedAt': capturedAt!.toUtc().toIso8601String(), + if (overrideReason != null && overrideReason!.isNotEmpty) + 'overrideReason': overrideReason, + if (nfcTagId != null) 'nfcTagId': nfcTagId, + if (proofNonce != null) 'proofNonce': proofNonce, + if (proofTimestamp != null) + 'proofTimestamp': proofTimestamp!.toUtc().toIso8601String(), + if (attestationProvider != null) + 'attestationProvider': attestationProvider, + if (attestationToken != null) 'attestationToken': attestationToken, + }; + } + @override - List get props => [notes, breakTimeMinutes, shiftId]; + List get props => [ + notes, + breakTimeMinutes, + shiftId, + deviceId, + latitude, + longitude, + accuracyMeters, + capturedAt, + overrideReason, + nfcTagId, + proofNonce, + proofTimestamp, + attestationProvider, + attestationToken, + ]; } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/repositories/clock_in_repository_interface.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/repositories/clock_in_repository_interface.dart index 9f93682b..2bd59a91 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/repositories/clock_in_repository_interface.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/repositories/clock_in_repository_interface.dart @@ -1,8 +1,11 @@ import 'package:krow_domain/krow_domain.dart'; -/// Repository interface for Clock In/Out functionality -abstract class ClockInRepositoryInterface { - +import '../arguments/clock_in_arguments.dart'; +import '../arguments/clock_out_arguments.dart'; + +/// Repository interface for Clock In/Out functionality. +abstract interface class ClockInRepositoryInterface { + /// Retrieves the shifts assigned to the user for the current day. /// Returns empty list if no shift is assigned for today. Future> getTodaysShifts(); @@ -11,17 +14,12 @@ abstract class ClockInRepositoryInterface { /// This helps in restoring the UI state if the app was killed. Future getAttendanceStatus(); - /// Checks the user in for the specified [shiftId]. + /// Checks the user in using the fields from [arguments]. /// Returns the updated [AttendanceStatus]. - Future clockIn({required String shiftId, String? notes}); + Future clockIn(ClockInArguments arguments); - /// Checks the user out for the currently active shift. + /// Checks the user out using the fields from [arguments]. /// - /// The V2 API resolves the assignment from [shiftId]. Optionally accepts - /// [breakTimeMinutes] if tracked. - Future clockOut({ - String? notes, - int? breakTimeMinutes, - String? shiftId, - }); + /// The V2 API resolves the assignment from the shift ID. + Future clockOut(ClockOutArguments arguments); } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_in_usecase.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_in_usecase.dart index b99b27f5..8938e627 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_in_usecase.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_in_usecase.dart @@ -11,9 +11,6 @@ class ClockInUseCase implements UseCase { @override Future call(ClockInArguments arguments) { - return _repository.clockIn( - shiftId: arguments.shiftId, - notes: arguments.notes, - ); + return _repository.clockIn(arguments); } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_out_usecase.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_out_usecase.dart index 22503897..df022d9b 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_out_usecase.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_out_usecase.dart @@ -11,10 +11,6 @@ class ClockOutUseCase implements UseCase { @override Future call(ClockOutArguments arguments) { - return _repository.clockOut( - notes: arguments.notes, - breakTimeMinutes: arguments.breakTimeMinutes, - shiftId: arguments.shiftId, - ); + return _repository.clockOut(arguments); } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/utils/time_window_utils.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/utils/time_window_utils.dart new file mode 100644 index 00000000..7222ab59 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/utils/time_window_utils.dart @@ -0,0 +1,75 @@ +import 'package:krow_domain/krow_domain.dart'; + +import '../validators/clock_in_validation_context.dart'; +import '../validators/validators/time_window_validator.dart'; + +/// Holds the computed time-window check-in/check-out availability flags. +class TimeWindowFlags { + /// Creates a [TimeWindowFlags] with default allowed values. + const TimeWindowFlags({ + this.isCheckInAllowed = true, + this.isCheckOutAllowed = true, + this.checkInAvailabilityTime, + this.checkOutAvailabilityTime, + }); + + /// Whether the time window currently allows check-in. + final bool isCheckInAllowed; + + /// Whether the time window currently allows check-out. + final bool isCheckOutAllowed; + + /// Formatted time when check-in becomes available, or `null`. + final String? checkInAvailabilityTime; + + /// Formatted time when check-out becomes available, or `null`. + final String? checkOutAvailabilityTime; +} + +/// Computes time-window check-in/check-out flags for the given [shift]. +/// +/// Returns a [TimeWindowFlags] indicating whether the current time falls +/// within the allowed clock-in and clock-out windows. Uses +/// [TimeWindowValidator] for the underlying validation logic. +TimeWindowFlags computeTimeWindowFlags(Shift? shift) { + if (shift == null) { + return const TimeWindowFlags(); + } + + const TimeWindowValidator validator = TimeWindowValidator(); + final DateTime shiftStart = shift.startsAt; + final DateTime shiftEnd = shift.endsAt; + + // Check-in window. + bool isCheckInAllowed = true; + String? checkInAvailabilityTime; + final ClockInValidationContext checkInCtx = ClockInValidationContext( + isCheckingIn: true, + shiftStartTime: shiftStart, + ); + isCheckInAllowed = validator.validate(checkInCtx).isValid; + if (!isCheckInAllowed) { + checkInAvailabilityTime = + TimeWindowValidator.getAvailabilityTime(shiftStart); + } + + // Check-out window. + bool isCheckOutAllowed = true; + String? checkOutAvailabilityTime; + final ClockInValidationContext checkOutCtx = ClockInValidationContext( + isCheckingIn: false, + shiftEndTime: shiftEnd, + ); + isCheckOutAllowed = validator.validate(checkOutCtx).isValid; + if (!isCheckOutAllowed) { + checkOutAvailabilityTime = + TimeWindowValidator.getAvailabilityTime(shiftEnd); + } + + return TimeWindowFlags( + isCheckInAllowed: isCheckInAllowed, + isCheckOutAllowed: isCheckOutAllowed, + checkInAvailabilityTime: checkInAvailabilityTime, + checkOutAvailabilityTime: checkOutAvailabilityTime, + ); +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart index eee69dcb..9c107915 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart @@ -4,21 +4,22 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../../domain/arguments/clock_in_arguments.dart'; -import '../../../domain/arguments/clock_out_arguments.dart'; -import '../../../domain/usecases/clock_in_usecase.dart'; -import '../../../domain/usecases/clock_out_usecase.dart'; -import '../../../domain/usecases/get_attendance_status_usecase.dart'; -import '../../../domain/usecases/get_todays_shift_usecase.dart'; -import '../../../domain/validators/clock_in_validation_context.dart'; -import '../../../domain/validators/clock_in_validation_result.dart'; -import '../../../domain/validators/validators/composite_clock_in_validator.dart'; -import '../../../domain/validators/validators/time_window_validator.dart'; -import '../geofence/geofence_bloc.dart'; -import '../geofence/geofence_event.dart'; -import '../geofence/geofence_state.dart'; -import 'clock_in_event.dart'; -import 'clock_in_state.dart'; +import 'package:staff_clock_in/src/data/services/background_geofence_service.dart'; +import 'package:staff_clock_in/src/domain/arguments/clock_in_arguments.dart'; +import 'package:staff_clock_in/src/domain/arguments/clock_out_arguments.dart'; +import 'package:staff_clock_in/src/domain/usecases/clock_in_usecase.dart'; +import 'package:staff_clock_in/src/domain/usecases/clock_out_usecase.dart'; +import 'package:staff_clock_in/src/domain/usecases/get_attendance_status_usecase.dart'; +import 'package:staff_clock_in/src/domain/usecases/get_todays_shift_usecase.dart'; +import 'package:staff_clock_in/src/domain/validators/clock_in_validation_context.dart'; +import 'package:staff_clock_in/src/domain/validators/clock_in_validation_result.dart'; +import 'package:staff_clock_in/src/domain/utils/time_window_utils.dart'; +import 'package:staff_clock_in/src/domain/validators/validators/composite_clock_in_validator.dart'; +import 'package:staff_clock_in/src/presentation/bloc/geofence/geofence_bloc.dart'; +import 'package:staff_clock_in/src/presentation/bloc/geofence/geofence_event.dart'; +import 'package:staff_clock_in/src/presentation/bloc/geofence/geofence_state.dart'; +import 'package:staff_clock_in/src/presentation/bloc/clock_in/clock_in_event.dart'; +import 'package:staff_clock_in/src/presentation/bloc/clock_in/clock_in_state.dart'; /// BLoC responsible for clock-in/clock-out operations and shift management. /// @@ -92,7 +93,7 @@ class ClockInBloc extends Bloc selectedShift ??= shifts.last; } - final _TimeWindowFlags timeFlags = _computeTimeWindowFlags( + final TimeWindowFlags timeFlags = computeTimeWindowFlags( selectedShift, ); @@ -122,7 +123,7 @@ class ClockInBloc extends Bloc ShiftSelected event, Emitter emit, ) { - final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(event.shift); + final TimeWindowFlags timeFlags = computeTimeWindowFlags(event.shift); emit(state.copyWith( selectedShift: event.shift, isCheckInAllowed: timeFlags.isCheckInAllowed, @@ -201,19 +202,51 @@ class ClockInBloc extends Bloc await handleError( emit: emit.call, action: () async { - final AttendanceStatus newStatus = await _clockIn( - ClockInArguments(shiftId: event.shiftId, notes: event.notes), - ); - emit(state.copyWith( - status: ClockInStatus.success, - attendance: newStatus, - )); + final DeviceLocation? location = geofenceState.currentLocation; - // Start background tracking after successful clock-in. - _dispatchBackgroundTrackingStarted( - event: event, - activeShiftId: newStatus.activeShiftId, - ); + try { + final AttendanceStatus newStatus = await _clockIn( + ClockInArguments( + shiftId: event.shiftId, + notes: event.notes, + latitude: location?.latitude, + longitude: location?.longitude, + accuracyMeters: location?.accuracy, + capturedAt: location?.timestamp, + overrideReason: geofenceState.isGeofenceOverridden + ? geofenceState.overrideNotes + : null, + ), + ); + emit(state.copyWith( + status: ClockInStatus.success, + attendance: newStatus, + )); + + // Start background tracking after successful clock-in. + _dispatchBackgroundTrackingStarted( + event: event, + activeShiftId: newStatus.activeShiftId, + ); + } on AppException catch (_) { + // The clock-in API call failed. Re-fetch attendance status to + // reconcile: if the worker is already clocked in (e.g. duplicate + // session from Postgres constraint 23505), treat it as success. + final AttendanceStatus currentStatus = await _getAttendanceStatus(); + if (currentStatus.isClockedIn) { + emit(state.copyWith( + status: ClockInStatus.success, + attendance: currentStatus, + )); + _dispatchBackgroundTrackingStarted( + event: event, + activeShiftId: currentStatus.activeShiftId, + ); + } else { + // Worker is genuinely not clocked in — surface the error. + rethrow; + } + } }, onError: (String errorKey) => state.copyWith( status: ClockInStatus.failure, @@ -224,34 +257,78 @@ class ClockInBloc extends Bloc /// Handles a clock-out request. /// + /// Emits a failure state and returns early when no active shift ID is + /// available — this prevents the API call from being made without a valid + /// shift reference. /// On success, dispatches [BackgroundTrackingStopped] to [_geofenceBloc]. Future _onCheckOut( CheckOutRequested event, Emitter emit, ) async { + final String? activeShiftId = state.attendance.activeShiftId; + if (activeShiftId == null) { + emit(state.copyWith( + status: ClockInStatus.failure, + errorMessage: 'errors.shift.no_active_shift', + )); + return; + } + emit(state.copyWith(status: ClockInStatus.actionInProgress)); await handleError( emit: emit.call, action: () async { - final AttendanceStatus newStatus = await _clockOut( - ClockOutArguments( - notes: event.notes, - breakTimeMinutes: event.breakTimeMinutes ?? 0, - shiftId: state.attendance.activeShiftId, - ), - ); - emit(state.copyWith( - status: ClockInStatus.success, - attendance: newStatus, - )); + final GeofenceState currentGeofence = _geofenceBloc.state; + final DeviceLocation? location = currentGeofence.currentLocation; - // Stop background tracking after successful clock-out. - _geofenceBloc.add( - BackgroundTrackingStopped( - clockOutTitle: event.clockOutTitle, - clockOutBody: event.clockOutBody, - ), - ); + try { + final AttendanceStatus newStatus = await _clockOut( + ClockOutArguments( + notes: event.notes, + breakTimeMinutes: event.breakTimeMinutes, + shiftId: activeShiftId, + latitude: location?.latitude, + longitude: location?.longitude, + accuracyMeters: location?.accuracy, + capturedAt: location?.timestamp, + overrideReason: currentGeofence.isGeofenceOverridden + ? currentGeofence.overrideNotes + : null, + ), + ); + emit(state.copyWith( + status: ClockInStatus.success, + attendance: newStatus, + )); + + // Stop background tracking after successful clock-out. + _geofenceBloc.add( + BackgroundTrackingStopped( + clockOutTitle: event.clockOutTitle, + clockOutBody: event.clockOutBody, + ), + ); + } on AppException catch (_) { + // The clock-out API call failed. Re-fetch attendance status to + // reconcile: if the worker is already clocked out (e.g. duplicate + // end-session), treat it as success. + final AttendanceStatus currentStatus = await _getAttendanceStatus(); + if (!currentStatus.isClockedIn) { + emit(state.copyWith( + status: ClockInStatus.success, + attendance: currentStatus, + )); + _geofenceBloc.add( + BackgroundTrackingStopped( + clockOutTitle: event.clockOutTitle, + clockOutBody: event.clockOutBody, + ), + ); + } else { + // Worker is still clocked in — surface the error. + rethrow; + } + } }, onError: (String errorKey) => state.copyWith( status: ClockInStatus.failure, @@ -269,7 +346,7 @@ class ClockInBloc extends Bloc Emitter emit, ) { if (state.status != ClockInStatus.success) return; - final _TimeWindowFlags timeFlags = _computeTimeWindowFlags( + final TimeWindowFlags timeFlags = computeTimeWindowFlags( state.selectedShift, ); emit(state.copyWith( @@ -299,52 +376,6 @@ class ClockInBloc extends Bloc return super.close(); } - /// Computes time-window check-in/check-out flags for the given [shift]. - /// - /// Uses [TimeWindowValidator] so this business logic stays out of widgets. - static _TimeWindowFlags _computeTimeWindowFlags(Shift? shift) { - if (shift == null) { - return const _TimeWindowFlags(); - } - - const TimeWindowValidator validator = TimeWindowValidator(); - final DateTime shiftStart = shift.startsAt; - final DateTime shiftEnd = shift.endsAt; - - // Check-in window. - bool isCheckInAllowed = true; - String? checkInAvailabilityTime; - final ClockInValidationContext checkInCtx = ClockInValidationContext( - isCheckingIn: true, - shiftStartTime: shiftStart, - ); - isCheckInAllowed = validator.validate(checkInCtx).isValid; - if (!isCheckInAllowed) { - checkInAvailabilityTime = - TimeWindowValidator.getAvailabilityTime(shiftStart); - } - - // Check-out window. - bool isCheckOutAllowed = true; - String? checkOutAvailabilityTime; - final ClockInValidationContext checkOutCtx = ClockInValidationContext( - isCheckingIn: false, - shiftEndTime: shiftEnd, - ); - isCheckOutAllowed = validator.validate(checkOutCtx).isValid; - if (!isCheckOutAllowed) { - checkOutAvailabilityTime = - TimeWindowValidator.getAvailabilityTime(shiftEnd); - } - - return _TimeWindowFlags( - isCheckInAllowed: isCheckInAllowed, - isCheckOutAllowed: isCheckOutAllowed, - checkInAvailabilityTime: checkInAvailabilityTime, - checkOutAvailabilityTime: checkOutAvailabilityTime, - ); - } - /// Dispatches [BackgroundTrackingStarted] to [_geofenceBloc] if the /// geofence has target coordinates. void _dispatchBackgroundTrackingStarted({ @@ -361,6 +392,9 @@ class ClockInBloc extends Bloc shiftId: activeShiftId, targetLat: geofenceState.targetLat!, targetLng: geofenceState.targetLng!, + geofenceRadiusMeters: + state.selectedShift?.geofenceRadiusMeters?.toDouble() ?? + BackgroundGeofenceService.defaultGeofenceRadiusMeters, greetingTitle: event.clockInGreetingTitle, greetingBody: event.clockInGreetingBody, leftGeofenceTitle: event.leftGeofenceTitle, @@ -370,26 +404,3 @@ class ClockInBloc extends Bloc } } } - -/// Internal value holder for time-window computation results. -class _TimeWindowFlags { - /// Creates a [_TimeWindowFlags] with default allowed values. - const _TimeWindowFlags({ - this.isCheckInAllowed = true, - this.isCheckOutAllowed = true, - this.checkInAvailabilityTime, - this.checkOutAvailabilityTime, - }); - - /// Whether the time window currently allows check-in. - final bool isCheckInAllowed; - - /// Whether the time window currently allows check-out. - final bool isCheckOutAllowed; - - /// Formatted time when check-in becomes available, or `null`. - final String? checkInAvailabilityTime; - - /// Formatted time when check-out becomes available, or `null`. - final String? checkOutAvailabilityTime; -} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart index d9c6a260..1616b7bf 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:developer' as developer; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; @@ -25,9 +26,11 @@ class GeofenceBloc extends Bloc required GeofenceServiceInterface geofenceService, required BackgroundGeofenceService backgroundGeofenceService, required ClockInNotificationService notificationService, + required AuthTokenProvider authTokenProvider, }) : _geofenceService = geofenceService, _backgroundGeofenceService = backgroundGeofenceService, _notificationService = notificationService, + _authTokenProvider = authTokenProvider, super(const GeofenceState.initial()) { on(_onStarted); on(_onResultUpdated); @@ -52,6 +55,17 @@ class GeofenceBloc extends Bloc /// The notification service for clock-in related notifications. final ClockInNotificationService _notificationService; + /// Provides fresh Firebase ID tokens for background isolate storage. + final AuthTokenProvider _authTokenProvider; + + /// Periodic timer that refreshes the auth token in SharedPreferences + /// so the background isolate always has a valid token for API calls. + Timer? _tokenRefreshTimer; + + /// How often to refresh the auth token for background use. + /// Set to 45 minutes — well before Firebase's 1-hour expiry. + static const Duration _tokenRefreshInterval = Duration(minutes: 45); + /// Active subscription to the foreground geofence location stream. StreamSubscription? _geofenceSubscription; @@ -239,6 +253,17 @@ class GeofenceBloc extends Bloc shiftId: event.shiftId, leftGeofenceTitle: event.leftGeofenceTitle, leftGeofenceBody: event.leftGeofenceBody, + geofenceRadiusMeters: event.geofenceRadiusMeters, + ); + + // Get and store initial auth token for background location streaming. + await _refreshAndStoreToken(); + + // Start periodic token refresh to keep it valid across long shifts. + _tokenRefreshTimer?.cancel(); + _tokenRefreshTimer = Timer.periodic( + _tokenRefreshInterval, + (_) => _refreshAndStoreToken(), ); // Show greeting notification using localized strings from the UI. @@ -261,6 +286,9 @@ class GeofenceBloc extends Bloc BackgroundTrackingStopped event, Emitter emit, ) async { + _tokenRefreshTimer?.cancel(); + _tokenRefreshTimer = null; + await handleError( emit: emit.call, action: () async { @@ -298,6 +326,8 @@ class GeofenceBloc extends Bloc GeofenceStopped event, Emitter emit, ) async { + _tokenRefreshTimer?.cancel(); + _tokenRefreshTimer = null; await _geofenceSubscription?.cancel(); _geofenceSubscription = null; await _serviceStatusSubscription?.cancel(); @@ -305,8 +335,26 @@ class GeofenceBloc extends Bloc emit(const GeofenceState.initial()); } + /// Fetches a fresh Firebase ID token and stores it in SharedPreferences + /// for the background isolate to use. + Future _refreshAndStoreToken() async { + try { + final String? token = await _authTokenProvider.getIdToken( + forceRefresh: true, + ); + if (token != null) { + await _backgroundGeofenceService.storeAuthToken(token); + } + } catch (e) { + // Best-effort — if token refresh fails, the background isolate will + // skip the POST (it checks for null/empty token). + developer.log('Token refresh failed: $e', name: 'GeofenceBloc', error: e); + } + } + @override Future close() { + _tokenRefreshTimer?.cancel(); _geofenceSubscription?.cancel(); _serviceStatusSubscription?.cancel(); return super.close(); diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart index 980d5c5d..bd3e2437 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart @@ -73,6 +73,7 @@ class BackgroundTrackingStarted extends GeofenceEvent { required this.greetingBody, required this.leftGeofenceTitle, required this.leftGeofenceBody, + this.geofenceRadiusMeters = 500, }); /// The shift ID being tracked. @@ -84,6 +85,9 @@ class BackgroundTrackingStarted extends GeofenceEvent { /// Target longitude of the shift location. final double targetLng; + /// Geofence radius in meters for this shift. Defaults to 500m. + final double geofenceRadiusMeters; + /// Localized greeting notification title passed from the UI layer. final String greetingTitle; @@ -103,6 +107,7 @@ class BackgroundTrackingStarted extends GeofenceEvent { shiftId, targetLat, targetLng, + geofenceRadiusMeters, greetingTitle, greetingBody, leftGeofenceTitle, diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/attendance_card.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/attendance_card.dart index fc187fdb..a4e0eef0 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/attendance_card.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/attendance_card.dart @@ -56,7 +56,7 @@ class AttendanceCard extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 2), + const SizedBox(height: UiConstants.space1), FittedBox( fit: BoxFit.scaleDown, child: Text( @@ -65,13 +65,13 @@ class AttendanceCard extends StatelessWidget { ), ), if (scheduledTime != null) ...[ - const SizedBox(height: 2), + const SizedBox(height: UiConstants.space1), Text( "Scheduled: $scheduledTime", style: UiTypography.footnote2r.textInactive, ), ], - const SizedBox(height: 2), + const SizedBox(height: UiConstants.space1), Text( subtitle, style: UiTypography.footnote1r.copyWith(color: UiColors.primary), diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart index a075096c..36b4a446 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart @@ -107,7 +107,7 @@ class ClockInActionSection extends StatelessWidget { return Column( mainAxisSize: MainAxisSize.min, children: [ - const GeofenceStatusBanner(), + GeofenceStatusBanner(isClockedIn: isCheckedIn), const SizedBox(height: UiConstants.space3), EarlyCheckInBanner( availabilityTime: checkInAvailabilityTime ?? soonLabel, @@ -120,7 +120,7 @@ class ClockInActionSection extends StatelessWidget { return Column( mainAxisSize: MainAxisSize.min, children: [ - const GeofenceStatusBanner(), + GeofenceStatusBanner(isClockedIn: isCheckedIn), const SizedBox(height: UiConstants.space3), EarlyCheckOutBanner( availabilityTime: checkOutAvailabilityTime ?? soonLabel, @@ -134,8 +134,9 @@ class ClockInActionSection extends StatelessWidget { final bool hasCoordinates = selectedShift?.latitude != null && selectedShift?.longitude != null; - // Geofence only gates clock-in, never clock-out. When already - // checked in the swipe must always be enabled for checkout. + // Geofence gates both clock-in and clock-out. When outside the + // geofence, the slider is locked until the worker provides a + // justification via the override modal. final bool isGeofenceBlocking = hasCoordinates && !geofenceState.isLocationVerified && @@ -146,7 +147,7 @@ class ClockInActionSection extends StatelessWidget { mainAxisSize: MainAxisSize.min, spacing: UiConstants.space4, children: [ - const GeofenceStatusBanner(), + GeofenceStatusBanner(isClockedIn: isCheckedIn), _currentInteraction.buildActionWidget( isCheckedIn: isCheckedIn, isDisabled: isGeofenceBlocking, diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart index 211769d1..bc2e2d2f 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart @@ -281,7 +281,7 @@ class _CommuteTrackerState extends State { size: 12, color: UiColors.textInactive, ), - const SizedBox(width: 2), + const SizedBox(width: UiConstants.space1), Text( i18n.starts_in(min: _getMinutesUntilShift().toString()), style: UiTypography.titleUppercase4m.textSecondary, diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/date_selector.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/date_selector.dart index c91be1a4..38df9665 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/date_selector.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/date_selector.dart @@ -55,7 +55,7 @@ class DateSelector extends StatelessWidget { : UiColors.foreground, ), ), - const SizedBox(height: 2), + const SizedBox(height: UiConstants.space1), Text( DateFormat('E').format(date), style: UiTypography.footnote2r.copyWith( diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_status_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_status_banner.dart index 115bb840..e129198e 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_status_banner.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_status_banner.dart @@ -4,6 +4,7 @@ import 'package:krow_domain/krow_domain.dart'; import '../../bloc/geofence/geofence_bloc.dart'; import '../../bloc/geofence/geofence_state.dart'; +import 'outside_work_area_banner.dart'; import 'permission_denied_banner.dart'; import 'permission_denied_forever_banner.dart'; import 'service_disabled_banner.dart'; @@ -17,9 +18,18 @@ import 'verifying_banner.dart'; /// /// Reads [GeofenceBloc] state directly and renders the appropriate /// banner variant based on permission, location, and verification conditions. +/// When [isClockedIn] is true and the worker is too far, a non-blocking +/// informational banner is shown instead of the override flow. class GeofenceStatusBanner extends StatelessWidget { /// Creates a [GeofenceStatusBanner]. - const GeofenceStatusBanner({super.key}); + const GeofenceStatusBanner({this.isClockedIn = false, super.key}); + + /// Whether the worker is currently clocked in. + /// + /// When true and the device is outside the geofence, a lightweight + /// [OutsideWorkAreaBanner] is shown instead of [TooFarBanner] so that + /// the clock-out slider remains accessible. + final bool isClockedIn; @override Widget build(BuildContext context) { @@ -77,6 +87,14 @@ class GeofenceStatusBanner extends StatelessWidget { if (!state.isLocationVerified && !state.isLocationTimedOut && state.distanceFromTarget != null) { + // When already clocked in, show a non-blocking informational banner + // instead of the "Clock in anyway" override flow so the clock-out + // slider remains accessible. + if (isClockedIn) { + return OutsideWorkAreaBanner( + distanceMeters: state.distanceFromTarget!, + ); + } return TooFarBanner(distanceMeters: state.distanceFromTarget!); } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/outside_work_area_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/outside_work_area_banner.dart new file mode 100644 index 00000000..84852567 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/outside_work_area_banner.dart @@ -0,0 +1,45 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_core/core.dart'; + +import 'banner_action_button.dart'; +import 'geofence_override_modal.dart'; + +/// Warning banner shown when the worker is clocked in but has moved outside +/// the geofence radius. +/// +/// Mirrors [TooFarBanner] with a "Clock out anyway" action that opens the +/// [GeofenceOverrideModal] so the worker can provide justification before +/// the clock-out slider unlocks. +class OutsideWorkAreaBanner extends StatelessWidget { + /// Creates an [OutsideWorkAreaBanner]. + const OutsideWorkAreaBanner({required this.distanceMeters, super.key}); + + /// Distance from the target location in meters. + final double distanceMeters; + + @override + Widget build(BuildContext context) { + final TranslationsStaffClockInGeofenceEn i18n = Translations.of( + context, + ).staff.clock_in.geofence; + + return UiNoticeBanner( + backgroundColor: UiColors.tagPending, + icon: UiIcons.warning, + iconColor: UiColors.textWarning, + title: i18n.outside_work_area_title, + titleColor: UiColors.textWarning, + description: i18n.outside_work_area_desc( + distance: formatDistance(distanceMeters), + ), + descriptionColor: UiColors.textWarning, + action: BannerActionButton( + label: i18n.clock_out_anyway, + color: UiColors.textWarning, + onPressed: () => GeofenceOverrideModal.show(context), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/location_map_placeholder.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/location_map_placeholder.dart index 16075931..6c29622f 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/location_map_placeholder.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/location_map_placeholder.dart @@ -27,7 +27,7 @@ class LocationMapPlaceholder extends StatelessWidget { // In a real app with keys, this would verify visually. // For now we use a generic placeholder color/icon to avoid broken images. fit: BoxFit.cover, - onError: (_, __) {}, + onError: (_, _) {}, ), ), child: Stack( diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart index 7aac190d..04f1fde2 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart @@ -228,7 +228,7 @@ class _LunchBreakDialogState extends State { ), ), ), - const SizedBox(width: 10), + const SizedBox(width: UiConstants.space2), Expanded( child: DropdownButtonFormField( isExpanded: true, diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart index 5681604c..0d5f230f 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart @@ -48,7 +48,13 @@ class ShiftCard extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded(child: _ShiftDetails(shift: shift, isSelected: isSelected, i18n: i18n)), + Expanded( + child: _ShiftDetails( + shift: shift, + isSelected: isSelected, + i18n: i18n, + ), + ), _ShiftTimeRange(shift: shift), ], ), @@ -76,16 +82,36 @@ class _ShiftDetails extends StatelessWidget { @override Widget build(BuildContext context) { + final String displayTitle = shift.roleName ?? shift.title; + final String? displaySubtitle = shift.clientName; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(shift.title, style: UiTypography.body2b), - // TODO: Ask BE to add clientName to the listTodayShifts response. - // Currently showing locationName as subtitle fallback. - Text( - shift.locationName ?? '', - style: UiTypography.body3r.textSecondary, - ), + Text(displayTitle, style: UiTypography.body2b), + if (displaySubtitle != null && displaySubtitle.isNotEmpty) + Text(displaySubtitle, style: UiTypography.body3r.textSecondary), + if (shift.locationName != null && shift.locationName!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: UiConstants.space1), + child: Row( + children: [ + const Icon( + UiIcons.mapPin, + size: 14, + color: UiColors.textSecondary, + ), + const SizedBox(width: UiConstants.space1), + Expanded( + child: Text( + shift.locationName!, + style: UiTypography.body3r.textSecondary, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), ], ); } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart index 671642ae..182f12ad 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart @@ -57,10 +57,10 @@ class StaffClockInModule extends Module { ); // Use Cases - i.add(GetTodaysShiftUseCase.new); - i.add(GetAttendanceStatusUseCase.new); - i.add(ClockInUseCase.new); - i.add(ClockOutUseCase.new); + i.addLazySingleton(GetTodaysShiftUseCase.new); + i.addLazySingleton(GetAttendanceStatusUseCase.new); + i.addLazySingleton(ClockInUseCase.new); + i.addLazySingleton(ClockOutUseCase.new); // Validators i.addLazySingleton( @@ -79,6 +79,7 @@ class StaffClockInModule extends Module { geofenceService: i.get(), backgroundGeofenceService: i.get(), notificationService: i.get(), + authTokenProvider: i.get(), ), ); i.add( diff --git a/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart b/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart index bc69b23c..1356f2de 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart @@ -30,4 +30,24 @@ class HomeRepositoryImpl implements HomeRepository { final ProfileCompletion completion = ProfileCompletion.fromJson(data); return completion.completed; } + + @override + Future> getBenefitsHistory({ + int limit = 20, + int offset = 0, + }) async { + final ApiResponse response = await _apiService.get( + StaffEndpoints.benefitsHistory, + params: { + 'limit': limit, + 'offset': offset, + }, + ); + final List items = + response.data['items'] as List? ?? []; + return items + .map((dynamic json) => + BenefitHistory.fromJson(json as Map)) + .toList(); + } } diff --git a/apps/mobile/packages/features/staff/home/lib/src/domain/repositories/home_repository.dart b/apps/mobile/packages/features/staff/home/lib/src/domain/repositories/home_repository.dart index 91144b86..c4f6005b 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/domain/repositories/home_repository.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/domain/repositories/home_repository.dart @@ -12,4 +12,10 @@ abstract class HomeRepository { /// Retrieves whether the staff member's profile is complete. Future getProfileCompletion(); + + /// Retrieves paginated benefit history for the staff member. + Future> getBenefitsHistory({ + int limit = 20, + int offset = 0, + }); } diff --git a/apps/mobile/packages/features/staff/home/lib/src/domain/usecases/get_benefits_history_usecase.dart b/apps/mobile/packages/features/staff/home/lib/src/domain/usecases/get_benefits_history_usecase.dart new file mode 100644 index 00000000..654d63cc --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/domain/usecases/get_benefits_history_usecase.dart @@ -0,0 +1,19 @@ +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_home/src/domain/repositories/home_repository.dart'; + +/// Use case for fetching paginated benefit history for a staff member. +/// +/// Delegates to [HomeRepository.getBenefitsHistory] and returns +/// a list of [BenefitHistory] records. +class GetBenefitsHistoryUseCase { + /// Creates a [GetBenefitsHistoryUseCase]. + GetBenefitsHistoryUseCase(this._repository); + + /// The repository used for data access. + final HomeRepository _repository; + + /// Executes the use case to fetch benefit history. + Future> call({int limit = 20, int offset = 0}) { + return _repository.getBenefitsHistory(limit: limit, offset: offset); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart index e53c19a1..f5b1e46a 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart @@ -2,22 +2,29 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:staff_home/src/domain/repositories/home_repository.dart'; +import 'package:staff_home/src/domain/usecases/get_benefits_history_usecase.dart'; +import 'package:staff_home/src/domain/usecases/get_home_shifts.dart'; part 'benefits_overview_state.dart'; /// Cubit managing the benefits overview page state. /// -/// Fetches the dashboard and extracts benefits for the detail page. +/// Fetches the dashboard benefits and lazily loads per-benefit history. class BenefitsOverviewCubit extends Cubit with BlocErrorHandler { /// Creates a [BenefitsOverviewCubit]. - BenefitsOverviewCubit({required HomeRepository repository}) - : _repository = repository, + BenefitsOverviewCubit({ + required GetDashboardUseCase getDashboard, + required GetBenefitsHistoryUseCase getBenefitsHistory, + }) : _getDashboard = getDashboard, + _getBenefitsHistory = getBenefitsHistory, super(const BenefitsOverviewState.initial()); - /// The repository used for data access. - final HomeRepository _repository; + /// Use case for fetching dashboard data. + final GetDashboardUseCase _getDashboard; + + /// Use case for fetching benefit history. + final GetBenefitsHistoryUseCase _getBenefitsHistory; /// Loads benefits from the dashboard endpoint. Future loadBenefits() async { @@ -26,7 +33,7 @@ class BenefitsOverviewCubit extends Cubit await handleError( emit: emit, action: () async { - final StaffDashboard dashboard = await _repository.getDashboard(); + final StaffDashboard dashboard = await _getDashboard(); if (isClosed) return; emit( state.copyWith( @@ -44,4 +51,96 @@ class BenefitsOverviewCubit extends Cubit }, ); } + + /// Loads benefit history for a specific benefit (lazy, on first expand). + /// + /// Skips if already loading or already loaded for the given [benefitId]. + Future loadBenefitHistory(String benefitId) async { + if (isClosed) return; + if (state.loadingHistoryIds.contains(benefitId)) return; + if (state.loadedHistoryIds.contains(benefitId)) return; + + emit(state.copyWith( + loadingHistoryIds: {...state.loadingHistoryIds, benefitId}, + )); + + await handleError( + emit: emit, + action: () async { + final List history = + await _getBenefitsHistory(limit: 20, offset: 0); + if (isClosed) return; + final List filtered = history + .where((BenefitHistory h) => h.benefitId == benefitId) + .toList(); + emit(state.copyWith( + historyByBenefitId: >{ + ...state.historyByBenefitId, + benefitId: filtered, + }, + loadingHistoryIds: {...state.loadingHistoryIds} + ..remove(benefitId), + loadedHistoryIds: {...state.loadedHistoryIds, benefitId}, + hasMoreHistory: { + ...state.hasMoreHistory, + benefitId: history.length >= 20, + }, + )); + }, + onError: (String errorKey) { + if (isClosed) return state; + return state.copyWith( + loadingHistoryIds: {...state.loadingHistoryIds} + ..remove(benefitId), + ); + }, + ); + } + + /// Loads more history for infinite scroll on the full history page. + /// + /// Appends results to existing history for the given [benefitId]. + Future loadMoreBenefitHistory(String benefitId) async { + if (isClosed) return; + if (state.loadingHistoryIds.contains(benefitId)) return; + if (!(state.hasMoreHistory[benefitId] ?? true)) return; + + final List existing = + state.historyByBenefitId[benefitId] ?? []; + + emit(state.copyWith( + loadingHistoryIds: {...state.loadingHistoryIds, benefitId}, + )); + + await handleError( + emit: emit, + action: () async { + final List history = + await _getBenefitsHistory(limit: 20, offset: existing.length); + if (isClosed) return; + final List filtered = history + .where((BenefitHistory h) => h.benefitId == benefitId) + .toList(); + emit(state.copyWith( + historyByBenefitId: >{ + ...state.historyByBenefitId, + benefitId: [...existing, ...filtered], + }, + loadingHistoryIds: {...state.loadingHistoryIds} + ..remove(benefitId), + hasMoreHistory: { + ...state.hasMoreHistory, + benefitId: history.length >= 20, + }, + )); + }, + onError: (String errorKey) { + if (isClosed) return state; + return state.copyWith( + loadingHistoryIds: {...state.loadingHistoryIds} + ..remove(benefitId), + ); + }, + ); + } } diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_state.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_state.dart index 768a2146..a5525a69 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_state.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_state.dart @@ -1,33 +1,78 @@ part of 'benefits_overview_cubit.dart'; +/// Status of the benefits overview data fetch. enum BenefitsOverviewStatus { initial, loading, loaded, error } +/// State for [BenefitsOverviewCubit]. +/// +/// Holds both the top-level benefits list and per-benefit history data +/// used by [BenefitHistoryPreview] and [BenefitHistoryPage]. class BenefitsOverviewState extends Equatable { - final BenefitsOverviewStatus status; - final List benefits; - final String? errorMessage; - + /// Creates a [BenefitsOverviewState]. const BenefitsOverviewState({ required this.status, - this.benefits = const [], + this.benefits = const [], this.errorMessage, + this.historyByBenefitId = const >{}, + this.loadingHistoryIds = const {}, + this.loadedHistoryIds = const {}, + this.hasMoreHistory = const {}, }); + /// Initial state with no data. const BenefitsOverviewState.initial() : this(status: BenefitsOverviewStatus.initial); + /// Current status of the top-level benefits fetch. + final BenefitsOverviewStatus status; + + /// The list of staff benefits. + final List benefits; + + /// Error message when [status] is [BenefitsOverviewStatus.error]. + final String? errorMessage; + + /// Cached history records keyed by benefit ID. + final Map> historyByBenefitId; + + /// Benefit IDs currently loading history. + final Set loadingHistoryIds; + + /// Benefit IDs whose history has been loaded at least once. + final Set loadedHistoryIds; + + /// Whether more pages of history are available per benefit. + final Map hasMoreHistory; + + /// Creates a copy with the given fields replaced. BenefitsOverviewState copyWith({ BenefitsOverviewStatus? status, List? benefits, String? errorMessage, + Map>? historyByBenefitId, + Set? loadingHistoryIds, + Set? loadedHistoryIds, + Map? hasMoreHistory, }) { return BenefitsOverviewState( status: status ?? this.status, benefits: benefits ?? this.benefits, errorMessage: errorMessage ?? this.errorMessage, + historyByBenefitId: historyByBenefitId ?? this.historyByBenefitId, + loadingHistoryIds: loadingHistoryIds ?? this.loadingHistoryIds, + loadedHistoryIds: loadedHistoryIds ?? this.loadedHistoryIds, + hasMoreHistory: hasMoreHistory ?? this.hasMoreHistory, ); } @override - List get props => [status, benefits, errorMessage]; + List get props => [ + status, + benefits, + errorMessage, + historyByBenefitId, + loadingHistoryIds, + loadedHistoryIds, + hasMoreHistory, + ]; } diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefit_history_page.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefit_history_page.dart new file mode 100644 index 00000000..57698f45 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefit_history_page.dart @@ -0,0 +1,123 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_home/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart'; +import 'package:staff_home/src/presentation/widgets/benefit_history_page/index.dart'; + +/// Full-screen page displaying paginated benefit history. +/// +/// Supports infinite scroll via [ScrollController] and +/// [BenefitsOverviewCubit.loadMoreBenefitHistory]. +class BenefitHistoryPage extends StatefulWidget { + /// Creates a [BenefitHistoryPage]. + const BenefitHistoryPage({ + required this.benefitId, + required this.benefitTitle, + super.key, + }); + + /// The ID of the benefit whose history to display. + final String benefitId; + + /// The human-readable benefit title shown in the app bar. + final String benefitTitle; + + @override + State createState() => _BenefitHistoryPageState(); +} + +class _BenefitHistoryPageState extends State { + /// Scroll controller for infinite scroll detection. + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + + final BenefitsOverviewCubit cubit = + Modular.get(); + if (!cubit.state.loadedHistoryIds.contains(widget.benefitId)) { + cubit.loadBenefitHistory(widget.benefitId); + } + } + + @override + void dispose() { + _scrollController + ..removeListener(_onScroll) + ..dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final dynamic i18n = t.staff.home.benefits.overview; + final String pageTitle = + i18n.history_page_title(benefit: widget.benefitTitle) as String; + + return Scaffold( + appBar: UiAppBar( + title: pageTitle, + showBackButton: true, + ), + body: BlocProvider.value( + value: Modular.get(), + child: BlocBuilder( + buildWhen: (BenefitsOverviewState previous, + BenefitsOverviewState current) => + previous.historyByBenefitId[widget.benefitId] != + current.historyByBenefitId[widget.benefitId] || + previous.loadingHistoryIds != current.loadingHistoryIds || + previous.loadedHistoryIds != current.loadedHistoryIds, + builder: (BuildContext context, BenefitsOverviewState state) { + final bool isLoading = + state.loadingHistoryIds.contains(widget.benefitId); + final bool isLoaded = + state.loadedHistoryIds.contains(widget.benefitId); + final List history = + state.historyByBenefitId[widget.benefitId] ?? + []; + final bool hasMore = + state.hasMoreHistory[widget.benefitId] ?? true; + + // Initial loading state + if (isLoading && !isLoaded) { + return const BenefitHistorySkeleton(); + } + + // Empty state + if (isLoaded && history.isEmpty) { + return BenefitHistoryEmptyState( + message: i18n.no_history as String, + ); + } + + // Loaded list with infinite scroll + return BenefitHistoryList( + history: history, + hasMore: hasMore, + isLoading: isLoading, + scrollController: _scrollController, + ); + }, + ), + ), + ); + } + + /// Triggers loading more history when scrolled near the bottom. + void _onScroll() { + if (!_scrollController.hasClients) return; + final double maxScroll = _scrollController.position.maxScrollExtent; + final double currentScroll = _scrollController.offset; + if (maxScroll - currentScroll <= 200) { + final BenefitsOverviewCubit cubit = + ReadContext(context).read(); + cubit.loadMoreBenefitHistory(widget.benefitId); + } + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart index fad93b89..56015812 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart @@ -19,9 +19,8 @@ class BenefitsOverviewPage extends StatelessWidget { subtitle: t.staff.home.benefits.overview.subtitle, showBackButton: true, ), - body: BlocProvider( - create: (context) => - Modular.get()..loadBenefits(), + body: BlocProvider.value( + value: Modular.get()..loadBenefits(), child: BlocBuilder( builder: (context, state) { if (state.status == BenefitsOverviewStatus.loading || diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefit_history_page/benefit_history_empty_state.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefit_history_page/benefit_history_empty_state.dart new file mode 100644 index 00000000..bcd9ccac --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefit_history_page/benefit_history_empty_state.dart @@ -0,0 +1,23 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Empty state shown when a benefit has no history entries. +class BenefitHistoryEmptyState extends StatelessWidget { + /// Creates a [BenefitHistoryEmptyState]. + const BenefitHistoryEmptyState({ + required this.message, + super.key, + }); + + /// The localized message displayed as the empty-state title. + final String message; + + @override + Widget build(BuildContext context) { + return UiEmptyState( + icon: UiIcons.clock, + title: message, + description: '', + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefit_history_page/benefit_history_list.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefit_history_page/benefit_history_list.dart new file mode 100644 index 00000000..47e70470 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefit_history_page/benefit_history_list.dart @@ -0,0 +1,53 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_home/src/presentation/widgets/benefits_overview/benefit_history_row.dart'; + +/// Scrollable list of [BenefitHistoryRow] items with a bottom loading +/// indicator for infinite-scroll pagination. +class BenefitHistoryList extends StatelessWidget { + /// Creates a [BenefitHistoryList]. + const BenefitHistoryList({ + required this.history, + required this.hasMore, + required this.isLoading, + required this.scrollController, + super.key, + }); + + /// The benefit history entries to display. + final List history; + + /// Whether additional pages are available to fetch. + final bool hasMore; + + /// Whether a page load is currently in progress. + final bool isLoading; + + /// Controller shared with the parent for infinite-scroll detection. + final ScrollController scrollController; + + @override + Widget build(BuildContext context) { + return ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space4, + ), + itemCount: history.length + (hasMore ? 1 : 0), + itemBuilder: (BuildContext context, int index) { + if (index >= history.length) { + // Bottom loading indicator + return isLoading + ? const Padding( + padding: EdgeInsets.all(UiConstants.space4), + child: Center(child: CircularProgressIndicator()), + ) + : const SizedBox.shrink(); + } + return BenefitHistoryRow(history: history[index]); + }, + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefit_history_page/benefit_history_skeleton.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefit_history_page/benefit_history_skeleton.dart new file mode 100644 index 00000000..6b3ec769 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefit_history_page/benefit_history_skeleton.dart @@ -0,0 +1,33 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer skeleton shown while the initial benefit history page loads. +class BenefitHistorySkeleton extends StatelessWidget { + /// Creates a [BenefitHistorySkeleton]. + const BenefitHistorySkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + children: [ + for (int i = 0; i < 8; i++) + Padding( + padding: + const EdgeInsets.symmetric(vertical: UiConstants.space2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + UiShimmerLine(width: 100, height: 14), + UiShimmerLine(width: 80, height: 14), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefit_history_page/index.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefit_history_page/index.dart new file mode 100644 index 00000000..ebf787a8 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefit_history_page/index.dart @@ -0,0 +1,3 @@ +export 'benefit_history_empty_state.dart'; +export 'benefit_history_list.dart'; +export 'benefit_history_skeleton.dart'; diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card.dart index 330bd8ee..24b1c3fe 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card.dart @@ -2,8 +2,9 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:staff_home/src/presentation/widgets/benefits_overview/benefit_card_header.dart'; +import 'package:staff_home/src/presentation/widgets/benefits_overview/benefit_history_preview.dart'; -/// Card widget displaying detailed benefit information. +/// Card widget displaying detailed benefit information with history preview. class BenefitCard extends StatelessWidget { /// Creates a [BenefitCard]. const BenefitCard({required this.benefit, super.key}); @@ -24,6 +25,11 @@ class BenefitCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ BenefitCardHeader(benefit: benefit), + const SizedBox(height: UiConstants.space4), + BenefitHistoryPreview( + benefitId: benefit.benefitId, + benefitTitle: benefit.title, + ), ], ), ); diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_history_preview.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_history_preview.dart new file mode 100644 index 00000000..78cf7529 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_history_preview.dart @@ -0,0 +1,177 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_home/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart'; +import 'package:staff_home/src/presentation/widgets/benefits_overview/benefit_history_row.dart'; + +/// Expandable preview section showing recent benefit history on a card. +/// +/// Collapses by default. On first expand, triggers a lazy load of history +/// for the given [benefitId] via [BenefitsOverviewCubit.loadBenefitHistory]. +/// Shows the first 5 records and a "Show all" button when more exist. +class BenefitHistoryPreview extends StatefulWidget { + /// Creates a [BenefitHistoryPreview]. + const BenefitHistoryPreview({ + required this.benefitId, + required this.benefitTitle, + super.key, + }); + + /// The ID of the benefit whose history to display. + final String benefitId; + + /// The human-readable benefit title, passed to the full history page. + final String benefitTitle; + + @override + State createState() => _BenefitHistoryPreviewState(); +} + +class _BenefitHistoryPreviewState extends State { + bool _isExpanded = false; + + @override + Widget build(BuildContext context) { + final dynamic i18n = t.staff.home.benefits.overview; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: UiConstants.space2, + children: [ + InkWell( + onTap: _toggleExpanded, + child: Padding( + padding: const EdgeInsets.only(top: UiConstants.space4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + i18n.history_header as String, + style: UiTypography.footnote2b.textSecondary, + ), + Icon( + _isExpanded ? UiIcons.chevronUp : UiIcons.chevronDown, + size: UiConstants.iconSm, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ), + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: _isExpanded ? _buildContent(i18n) : const SizedBox.shrink(), + ), + ], + ); + } + + /// Toggles expansion and triggers history load on first expand. + void _toggleExpanded() { + setState(() { + _isExpanded = !_isExpanded; + }); + if (_isExpanded) { + final BenefitsOverviewCubit cubit = + ReadContext(context).read(); + cubit.loadBenefitHistory(widget.benefitId); + } + } + + /// Builds the expanded content section. + Widget _buildContent(dynamic i18n) { + return BlocBuilder( + buildWhen: (BenefitsOverviewState previous, + BenefitsOverviewState current) => + previous.historyByBenefitId[widget.benefitId] != + current.historyByBenefitId[widget.benefitId] || + previous.loadingHistoryIds != current.loadingHistoryIds || + previous.loadedHistoryIds != current.loadedHistoryIds, + builder: (BuildContext context, BenefitsOverviewState state) { + final bool isLoading = + state.loadingHistoryIds.contains(widget.benefitId); + final bool isLoaded = + state.loadedHistoryIds.contains(widget.benefitId); + final List history = + state.historyByBenefitId[widget.benefitId] ?? []; + + if (isLoading && !isLoaded) { + return _buildShimmer(); + } + + if (isLoaded && history.isEmpty) { + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: Text( + i18n.no_history as String, + style: UiTypography.body3r.textSecondary, + ), + ); + } + + final int previewCount = history.length > 5 ? 5 : history.length; + final bool showAll = history.length > 5; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < previewCount; i++) + BenefitHistoryRow(history: history[i]), + if (!showAll) + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: _navigateToFullHistory, + child: Text( + i18n.show_all as String, + style: UiTypography.footnote1m.copyWith( + color: UiColors.primary, + ), + ), + ), + ), + ], + ); + }, + ); + } + + /// Builds shimmer placeholder rows while loading. + Widget _buildShimmer() { + return UiShimmer( + child: Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: Column( + children: [ + for (int i = 0; i < 3; i++) + Padding( + padding: + const EdgeInsets.symmetric(vertical: UiConstants.space2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + UiShimmerLine(width: 100, height: 12), + UiShimmerLine(width: 60, height: 12), + ], + ), + ), + ], + ), + ), + ); + } + + /// Navigates to the full benefit history page. + void _navigateToFullHistory() { + Modular.to.toBenefitHistory( + benefitId: widget.benefitId, + benefitTitle: widget.benefitTitle, + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_history_row.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_history_row.dart new file mode 100644 index 00000000..6edaf78b --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_history_row.dart @@ -0,0 +1,132 @@ +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 'package:krow_domain/krow_domain.dart'; + +/// A single row displaying one [BenefitHistory] record. +/// +/// Shows the effective date, optional notes, accrued hours badge, and a +/// status chip. Used in both [BenefitHistoryPreview] and [BenefitHistoryPage]. +class BenefitHistoryRow extends StatelessWidget { + /// Creates a [BenefitHistoryRow]. + const BenefitHistoryRow({required this.history, super.key}); + + /// The history record to display. + final BenefitHistory history; + + @override + Widget build(BuildContext context) { + final dynamic i18n = t.staff.home.benefits.overview; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: UiConstants.space2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Left: notes + date + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (history.notes != null && history.notes!.isNotEmpty) + Text( + history.notes!, + style: UiTypography.body2r, + ), + const SizedBox(height: UiConstants.space2), + Text( + DateFormat('d MMM, yyyy').format(history.effectiveAt), + style: UiTypography.footnote1r.textSecondary, + ), + ], + ), + ), + const SizedBox(width: UiConstants.space3), + // Right: status chip + hours badge + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _buildStatusChip(i18n), + const SizedBox(height: UiConstants.space2), + _buildHoursBadge(i18n), + ], + ), + ], + ), + ); + } + + /// Builds the hours badge showing tracked hours. + Widget _buildHoursBadge(dynamic i18n) { + final String label = '+${history.trackedHours}h'; + return Text( + label, + style: UiTypography.footnote1r.textSecondary, + ); + } + + /// Builds a chip indicating the benefit history status. + Widget _buildStatusChip(dynamic i18n) { + final _StatusStyle statusStyle = _resolveStatusStyle(history.status); + return Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: UiConstants.space1, + ), + decoration: BoxDecoration( + color: statusStyle.backgroundColor, + borderRadius: UiConstants.radiusFull, + border: Border.all(color: statusStyle.borderColor, width: 0.5), + ), + child: Text( + statusStyle.label, + style: UiTypography.footnote2m.copyWith(color: statusStyle.textColor), + ), + ); + } + + /// Maps a [BenefitStatus] to display style values. + _StatusStyle _resolveStatusStyle(BenefitStatus status) { + final dynamic i18n = t.staff.home.benefits.overview.status; + switch (status) { + case BenefitStatus.active: + return _StatusStyle( + label: i18n.submitted, + backgroundColor: UiColors.tagSuccess, + textColor: UiColors.textSuccess, + borderColor: UiColors.tagSuccess, + ); + case BenefitStatus.pending: + return _StatusStyle( + label: i18n.pending, + backgroundColor: UiColors.tagPending, + textColor: UiColors.mutedForeground, + borderColor: UiColors.border, + ); + case BenefitStatus.inactive: + case BenefitStatus.unknown: + return _StatusStyle( + label: i18n.pending, + backgroundColor: UiColors.muted, + textColor: UiColors.mutedForeground, + borderColor: UiColors.border, + ); + } + } +} + +/// Internal value type for status chip styling. +class _StatusStyle { + const _StatusStyle({ + required this.label, + required this.backgroundColor, + required this.textColor, + required this.borderColor, + }); + + final String label; + final Color backgroundColor; + final Color textColor; + final Color borderColor; +} 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 index 2892b948..1abc020e 100644 --- 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 @@ -1,10 +1,9 @@ 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'; +import 'package:staff_home/src/presentation/widgets/home_page/home_page_skeleton/quick_actions_skeleton.dart'; +import 'package:staff_home/src/presentation/widgets/home_page/home_page_skeleton/recommended_section_skeleton.dart'; +import 'package:staff_home/src/presentation/widgets/home_page/home_page_skeleton/shift_section_skeleton.dart'; +import 'package:staff_home/src/presentation/widgets/home_page/home_page_skeleton/skeleton_divider.dart'; /// Shimmer loading skeleton for the staff home page. /// 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 index f8ffc72a..0ad061e7 100644 --- 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 @@ -1,7 +1,6 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; - -import 'shift_card_skeleton.dart'; +import 'package:staff_home/src/presentation/widgets/home_page/home_page_skeleton/shift_card_skeleton.dart'; /// Skeleton for a shift section (section header + 2 shift card placeholders). class ShiftSectionSkeleton extends StatelessWidget { diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_shift_card.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_shift_card.dart new file mode 100644 index 00000000..14ff8d4a --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_shift_card.dart @@ -0,0 +1,193 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +/// A reusable compact card for displaying shift information on the home page. +/// +/// Accepts display-ready primitive fields so it works with any shift type +/// (today shifts, tomorrow shifts, etc.). +class HomeShiftCard extends StatelessWidget { + /// Creates a [HomeShiftCard]. + const HomeShiftCard({ + super.key, + required this.shiftId, + required this.title, + this.subtitle, + required this.location, + required this.startTime, + required this.endTime, + this.hourlyRate, + this.totalRate, + this.onTap, + }); + + /// Unique identifier of the shift. + final String shiftId; + + /// Primary display text (client name or role name). + final String title; + + /// Secondary display text (role name when title is client name). + final String? subtitle; + + /// Location address to display. + final String location; + + /// Shift start time. + final DateTime startTime; + + /// Shift end time. + final DateTime endTime; + + /// Hourly rate in dollars, null if not available. + final double? hourlyRate; + + /// Total rate in dollars, null if not available. + final double? totalRate; + + /// Callback when the card is tapped. + final VoidCallback? onTap; + + /// Formats a [DateTime] as a lowercase 12-hour time string (e.g. "9:00am"). + String _formatTime(DateTime time) { + return DateFormat('h:mma').format(time).toLowerCase(); + } + + /// Computes the shift duration in whole hours. + double _durationHours() { + final int minutes = endTime.difference(startTime).inMinutes; + double hours = minutes / 60; + if (hours < 0) hours += 24; + return hours.roundToDouble(); + } + + @override + Widget build(BuildContext context) { + final bool hasRate = hourlyRate != null && hourlyRate! > 0; + final double durationHours = _durationHours(); + final double estimatedTotal = (totalRate != null && totalRate! > 0) + ? totalRate! + : (hourlyRate ?? 0) * durationHours; + + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: UiConstants.space3), + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all(color: UiColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + spacing: UiConstants.space3, + children: [ + Container( + width: UiConstants.space10, + height: UiConstants.space10, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), + border: Border.all(color: UiColors.border), + ), + child: Icon( + UiIcons.building, + size: UiConstants.space5, + color: UiColors.mutedForeground, + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: UiTypography.body1m.textPrimary, + overflow: TextOverflow.ellipsis, + ), + + if (subtitle != null) + Text( + subtitle!, + style: UiTypography.body3r.textSecondary, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ], + ), + + if (hasRate) ...[ + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '\$${estimatedTotal.toStringAsFixed(0)}', + style: UiTypography.title1m.textPrimary, + ), + Text( + '\$${hourlyRate!.toInt()}/hr \u00b7 ${durationHours.toInt()}h', + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), + ], + ], + ), + + const SizedBox(height: UiConstants.space3), + + // Time and location row + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: UiConstants.space1, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + UiIcons.clock, + size: UiConstants.space3, + color: UiColors.mutedForeground, + ), + const SizedBox(width: UiConstants.space1), + Text( + '${_formatTime(startTime)} - ${_formatTime(endTime)}', + style: UiTypography.body3r.textSecondary, + ), + ], + ), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + UiIcons.mapPin, + size: UiConstants.space3, + color: UiColors.mutedForeground, + ), + const SizedBox(width: UiConstants.space1), + Expanded( + child: Text( + location, + style: UiTypography.body3r.textSecondary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart index 0f518d9d..ba255380 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart @@ -21,11 +21,20 @@ class RecommendedShiftCard extends StatelessWidget { return DateFormat('h:mma').format(time).toLowerCase(); } + /// Computes the shift duration in whole hours. + double _durationHours() { + final int minutes = shift.endTime.difference(shift.startTime).inMinutes; + double hours = minutes / 60; + if (hours < 0) hours += 24; + return hours.roundToDouble(); + } + @override Widget build(BuildContext context) { final dynamic recI18n = t.staff.home.recommended_card; final Size size = MediaQuery.sizeOf(context); - final double hourlyRate = shift.hourlyRateCents / 100; + final double durationHours = _durationHours(); + final double estimatedTotal = shift.hourlyRate * durationHours; return GestureDetector( onTap: () => Modular.to.toShiftDetailsById(shift.shiftId), @@ -69,14 +78,16 @@ class RecommendedShiftCard extends StatelessWidget { children: [ Flexible( child: Text( - shift.roleName, + shift.roleName.isNotEmpty + ? shift.roleName + : shift.clientName, style: UiTypography.body1m.textPrimary, overflow: TextOverflow.ellipsis, ), ), Text( - '\$${hourlyRate.toStringAsFixed(0)}/h', - style: UiTypography.headline4b, + '\$${estimatedTotal.toStringAsFixed(0)}', + style: UiTypography.title1m.textPrimary, ), ], ), @@ -85,12 +96,14 @@ class RecommendedShiftCard extends StatelessWidget { spacing: UiConstants.space1, children: [ Text( - shift.orderType.toJson(), + shift.clientName.isNotEmpty + ? shift.clientName + : shift.orderType.toJson(), style: UiTypography.body3r.textSecondary, ), Text( - '\$${hourlyRate.toStringAsFixed(0)}/hr', - style: UiTypography.body3r.textSecondary, + '\$${shift.hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h', + style: UiTypography.footnote2r.textSecondary, ), ], ), diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/section_layout.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/section_layout.dart index 4d21c539..222dd961 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/section_layout.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/section_layout.dart @@ -1,7 +1,6 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; - -import 'section_header.dart'; +import 'package:staff_home/src/presentation/widgets/home_page/section_header.dart'; /// A common layout widget for home page sections. /// 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 ea0e376c..fb3278c5 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 @@ -3,12 +3,12 @@ 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 'package:staff_home/src/presentation/blocs/home/home_cubit.dart'; import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.dart'; +import 'package:staff_home/src/presentation/widgets/home_page/home_shift_card.dart'; import 'package:staff_home/src/presentation/widgets/home_page/section_layout.dart'; /// A widget that displays today's shifts section. @@ -45,7 +45,29 @@ class TodaysShiftsSection extends StatelessWidget { : Column( children: shifts .map( - (TodayShift shift) => _TodayShiftCard(shift: shift), + (TodayShift shift) => HomeShiftCard( + shiftId: shift.shiftId, + title: shift.roleName.isNotEmpty + ? shift.roleName + : shift.clientName, + subtitle: shift.clientName.isNotEmpty + ? shift.clientName + : null, + location: + shift.locationAddress?.isNotEmpty == true + ? shift.locationAddress! + : shift.location, + startTime: shift.startTime, + endTime: shift.endTime, + hourlyRate: shift.hourlyRate > 0 + ? shift.hourlyRate + : null, + totalRate: shift.totalRate > 0 + ? shift.totalRate + : null, + onTap: () => Modular.to + .toShiftDetailsById(shift.shiftId), + ), ) .toList(), ), @@ -55,70 +77,6 @@ class TodaysShiftsSection extends StatelessWidget { } } -/// Compact card for a today's shift. -class _TodayShiftCard extends StatelessWidget { - const _TodayShiftCard({required this.shift}); - - /// The today-shift to display. - final TodayShift shift; - - String _formatTime(DateTime time) { - return DateFormat('h:mma').format(time).toLowerCase(); - } - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () => Modular.to.toShiftDetailsById(shift.shiftId), - child: Container( - margin: const EdgeInsets.only(bottom: UiConstants.space3), - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all(color: UiColors.border), - ), - child: Row( - children: [ - Container( - width: UiConstants.space12, - height: UiConstants.space12, - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: - BorderRadius.circular(UiConstants.radiusBase), - border: Border.all(color: UiColors.border), - ), - child: Icon( - UiIcons.building, - color: UiColors.mutedForeground, - ), - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - shift.roleName, - style: UiTypography.body1m.textPrimary, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: UiConstants.space1), - Text( - '${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)} \u2022 ${shift.location}', - style: UiTypography.body3r.textSecondary, - ), - ], - ), - ), - ], - ), - ), - ); - } -} - /// Inline shimmer skeleton for the shifts section loading state. class _ShiftsSectionSkeleton extends StatelessWidget { const _ShiftsSectionSkeleton(); diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/tomorrows_shifts_section.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/tomorrows_shifts_section.dart index da46d3cf..0c045f7f 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/tomorrows_shifts_section.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/tomorrows_shifts_section.dart @@ -1,14 +1,13 @@ 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 'package:staff_home/src/presentation/blocs/home/home_cubit.dart'; import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.dart'; +import 'package:staff_home/src/presentation/widgets/home_page/home_shift_card.dart'; import 'package:staff_home/src/presentation/widgets/home_page/section_layout.dart'; /// A widget that displays tomorrow's shifts section. @@ -35,8 +34,26 @@ class TomorrowsShiftsSection extends StatelessWidget { : Column( children: shifts .map( - (AssignedShift shift) => - _TomorrowShiftCard(shift: shift), + (AssignedShift shift) => HomeShiftCard( + shiftId: shift.shiftId, + title: shift.clientName.isNotEmpty + ? shift.clientName + : shift.roleName, + subtitle: shift.clientName.isNotEmpty + ? shift.roleName + : null, + location: shift.location, + startTime: shift.startTime, + endTime: shift.endTime, + hourlyRate: shift.hourlyRate > 0 + ? shift.hourlyRate + : null, + totalRate: shift.totalRate > 0 + ? shift.totalRate + : null, + onTap: () => Modular.to + .toShiftDetailsById(shift.shiftId), + ), ) .toList(), ), @@ -45,89 +62,3 @@ class TomorrowsShiftsSection extends StatelessWidget { ); } } - -/// Compact card for a tomorrow's shift. -class _TomorrowShiftCard extends StatelessWidget { - const _TomorrowShiftCard({required this.shift}); - - /// The assigned shift to display. - final AssignedShift shift; - - String _formatTime(DateTime time) { - return DateFormat('h:mma').format(time).toLowerCase(); - } - - @override - Widget build(BuildContext context) { - final double hourlyRate = shift.hourlyRateCents / 100; - - return GestureDetector( - onTap: () => Modular.to.toShiftDetailsById(shift.shiftId), - child: Container( - margin: const EdgeInsets.only(bottom: UiConstants.space3), - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all(color: UiColors.border), - ), - child: Row( - children: [ - Container( - width: UiConstants.space12, - height: UiConstants.space12, - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: - BorderRadius.circular(UiConstants.radiusBase), - border: Border.all(color: UiColors.border), - ), - child: Icon( - UiIcons.building, - color: UiColors.mutedForeground, - ), - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: Text( - shift.roleName, - style: UiTypography.body1m.textPrimary, - overflow: TextOverflow.ellipsis, - ), - ), - Text.rich( - TextSpan( - text: - '\$${hourlyRate % 1 == 0 ? hourlyRate.toInt() : hourlyRate.toStringAsFixed(2)}', - style: UiTypography.body1b.textPrimary, - children: [ - TextSpan( - text: '/h', - style: UiTypography.body3r, - ), - ], - ), - ), - ], - ), - const SizedBox(height: UiConstants.space1), - Text( - '${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)} \u2022 ${shift.location}', - style: UiTypography.body3r.textSecondary, - ), - ], - ), - ), - ], - ), - ), - ); - } -} 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 bcb4af20..7f468310 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 @@ -4,9 +4,11 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:staff_home/src/data/repositories/home_repository_impl.dart'; import 'package:staff_home/src/domain/repositories/home_repository.dart'; +import 'package:staff_home/src/domain/usecases/get_benefits_history_usecase.dart'; import 'package:staff_home/src/domain/usecases/get_home_shifts.dart'; import 'package:staff_home/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart'; import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart'; +import 'package:staff_home/src/presentation/pages/benefit_history_page.dart'; import 'package:staff_home/src/presentation/pages/benefits_overview_page.dart'; import 'package:staff_home/src/presentation/pages/worker_home_page.dart'; @@ -33,6 +35,9 @@ class StaffHomeModule extends Module { i.addLazySingleton( () => GetProfileCompletionUseCase(i.get()), ); + i.addLazySingleton( + () => GetBenefitsHistoryUseCase(i.get()), + ); // Presentation layer - Cubits i.addLazySingleton( @@ -42,9 +47,12 @@ class StaffHomeModule extends Module { ), ); - // Cubit for benefits overview page + // Cubit for benefits overview page (includes history support) i.addLazySingleton( - () => BenefitsOverviewCubit(repository: i.get()), + () => BenefitsOverviewCubit( + getDashboard: i.get(), + getBenefitsHistory: i.get(), + ), ); } @@ -58,5 +66,16 @@ class StaffHomeModule extends Module { StaffPaths.childRoute(StaffPaths.home, StaffPaths.benefits), child: (BuildContext context) => const BenefitsOverviewPage(), ); + r.child( + StaffPaths.childRoute(StaffPaths.home, StaffPaths.benefitHistory), + child: (BuildContext context) { + final Map? args = + r.args.data as Map?; + return BenefitHistoryPage( + benefitId: args?['benefitId'] as String? ?? '', + benefitTitle: args?['benefitTitle'] as String? ?? '', + ); + }, + ); } } diff --git a/apps/mobile/packages/features/staff/payments/lib/src/payments_module.dart b/apps/mobile/packages/features/staff/payments/lib/src/payments_module.dart index 026ea2ff..551170b7 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/payments_module.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/payments_module.dart @@ -1,8 +1,7 @@ -import 'package:flutter/src/widgets/framework.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; - import 'package:staff_payments/src/data/repositories/payments_repository_impl.dart'; import 'package:staff_payments/src/domain/repositories/payments_repository.dart'; import 'package:staff_payments/src/domain/usecases/get_payment_chart_usecase.dart'; diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/early_pay_page.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/early_pay_page.dart index d9c6716c..1e4f7803 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/early_pay_page.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/early_pay_page.dart @@ -27,7 +27,7 @@ class EarlyPayPage extends StatelessWidget { border: Border.all(color: UiColors.primary.withValues(alpha: 0.1)), ), child: Column( - children: [ + children: [ Text( context.t.staff_payments.early_pay.available_label, style: UiTypography.body2m.textSecondary, @@ -65,13 +65,13 @@ class EarlyPayPage extends StatelessWidget { border: Border.all(color: UiColors.separatorPrimary), ), child: Row( - children: [ + children: [ const Icon(UiIcons.bank, size: 24, color: UiColors.primary), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ Text('Chase Bank', style: UiTypography.body2b.textPrimary), Text('Ending in 4321', style: UiTypography.footnote2r.textSecondary), ], 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 index 2d24c1ae..09c646a5 100644 --- 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 @@ -18,13 +18,13 @@ class PaymentItemSkeleton extends StatelessWidget { borderRadius: UiConstants.radiusLg, ), child: const Row( - children: [ + children: [ UiShimmerCircle(size: 40), SizedBox(width: UiConstants.space3), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ UiShimmerLine(width: 140, height: 14), SizedBox(height: UiConstants.space2), UiShimmerLine(width: 100, height: 12), 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 index 45de7a7a..245a24d2 100644 --- 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 @@ -16,12 +16,12 @@ class PaymentsPageSkeleton extends StatelessWidget { return UiShimmer( child: SingleChildScrollView( child: Column( - children: [ + children: [ // Header section with gradient Container( decoration: BoxDecoration( gradient: LinearGradient( - colors: [ + colors: [ UiColors.primary, UiColors.primary.withValues(alpha: 0.8), ], @@ -37,7 +37,7 @@ class PaymentsPageSkeleton extends StatelessWidget { ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ // Title placeholder const UiShimmerLine(width: 120, height: 24), const SizedBox(height: UiConstants.space6), @@ -45,7 +45,7 @@ class PaymentsPageSkeleton extends StatelessWidget { // Balance center const Center( child: Column( - children: [ + children: [ UiShimmerLine(width: 100, height: 14), SizedBox(height: UiConstants.space1), UiShimmerLine(width: 160, height: 36), @@ -73,7 +73,7 @@ class PaymentsPageSkeleton extends StatelessWidget { ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ // Earnings graph placeholder UiShimmerBox( width: double.infinity, @@ -83,10 +83,10 @@ class PaymentsPageSkeleton extends StatelessWidget { const SizedBox(height: UiConstants.space6), // Quick stats row - Row( - children: [ + const Row( + children: [ Expanded(child: UiShimmerStatsCard()), - const SizedBox(width: UiConstants.space3), + SizedBox(width: UiConstants.space3), Expanded(child: UiShimmerStatsCard()), ], ), @@ -99,7 +99,7 @@ class PaymentsPageSkeleton extends StatelessWidget { // Payment history items UiShimmerList( itemCount: 4, - itemBuilder: (index) => const PaymentItemSkeleton(), + itemBuilder: (int index) => const PaymentItemSkeleton(), ), ], ), diff --git a/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart b/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart index 606d08f0..f8d3020d 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart @@ -1,17 +1,19 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart'; + /// Repository implementation for the main profile page. /// /// Uses the V2 API to fetch staff profile, section statuses, and completion. -class ProfileRepositoryImpl { +class ProfileRepositoryImpl implements ProfileRepositoryInterface { /// Creates a [ProfileRepositoryImpl]. ProfileRepositoryImpl({required BaseApiService apiService}) : _api = apiService; final BaseApiService _api; - /// Fetches the staff profile from the V2 session endpoint. + @override Future getStaffProfile() async { final ApiResponse response = await _api.get(StaffEndpoints.session); @@ -20,7 +22,7 @@ class ProfileRepositoryImpl { return Staff.fromJson(json); } - /// Fetches the profile section completion statuses. + @override Future getProfileSections() async { final ApiResponse response = await _api.get(StaffEndpoints.profileSections); @@ -29,7 +31,7 @@ class ProfileRepositoryImpl { return ProfileSectionStatus.fromJson(json); } - /// Signs out the current user. + @override Future signOut() async { await _api.post(AuthEndpoints.signOut); } diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository_interface.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository_interface.dart new file mode 100644 index 00000000..b28b963d --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository_interface.dart @@ -0,0 +1,16 @@ +import 'package:krow_domain/krow_domain.dart'; + +/// Abstract interface for the staff profile repository. +/// +/// Defines the contract for fetching staff profile data, +/// section completion statuses, and signing out. +abstract interface class ProfileRepositoryInterface { + /// Fetches the staff profile from the backend. + Future getStaffProfile(); + + /// Fetches the profile section completion statuses. + Future getProfileSections(); + + /// Signs out the current user. + Future signOut(); +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_profile_sections_usecase.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_profile_sections_usecase.dart new file mode 100644 index 00000000..21bbaee3 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_profile_sections_usecase.dart @@ -0,0 +1,17 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart'; + +/// Use case for retrieving profile section completion statuses. +class GetProfileSectionsUseCase implements NoInputUseCase { + /// Creates a [GetProfileSectionsUseCase] with the required [repository]. + GetProfileSectionsUseCase(this._repository); + + final ProfileRepositoryInterface _repository; + + @override + Future call() { + return _repository.getProfileSections(); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_staff_profile_usecase.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_staff_profile_usecase.dart new file mode 100644 index 00000000..de53df83 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_staff_profile_usecase.dart @@ -0,0 +1,17 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart'; + +/// Use case for retrieving the staff member's profile. +class GetStaffProfileUseCase implements NoInputUseCase { + /// Creates a [GetStaffProfileUseCase] with the required [repository]. + GetStaffProfileUseCase(this._repository); + + final ProfileRepositoryInterface _repository; + + @override + Future call() { + return _repository.getStaffProfile(); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/sign_out_usecase.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/sign_out_usecase.dart new file mode 100644 index 00000000..301b6b90 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/sign_out_usecase.dart @@ -0,0 +1,16 @@ +import 'package:krow_core/core.dart'; + +import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart'; + +/// Use case for signing out the current user. +class SignOutUseCase implements NoInputUseCase { + /// Creates a [SignOutUseCase] with the required [repository]. + SignOutUseCase(this._repository); + + final ProfileRepositoryInterface _repository; + + @override + Future call() { + return _repository.signOut(); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart index ec70c614..4b68e2ee 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart @@ -2,19 +2,30 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:staff_profile/src/data/repositories/profile_repository_impl.dart'; +import 'package:staff_profile/src/domain/usecases/get_profile_sections_usecase.dart'; +import 'package:staff_profile/src/domain/usecases/get_staff_profile_usecase.dart'; +import 'package:staff_profile/src/domain/usecases/sign_out_usecase.dart'; import 'package:staff_profile/src/presentation/blocs/profile_state.dart'; /// Cubit for managing the Profile feature state. /// -/// Uses the V2 API via [ProfileRepositoryImpl] for all data fetching. +/// Delegates all data fetching to use cases, following Clean Architecture. /// Loads the staff profile and section completion statuses in a single flow. class ProfileCubit extends Cubit with BlocErrorHandler { - /// Creates a [ProfileCubit] with the required repository. - ProfileCubit(this._repository) : super(const ProfileState()); + /// Creates a [ProfileCubit] with the required use cases. + ProfileCubit({ + required GetStaffProfileUseCase getStaffProfileUseCase, + required GetProfileSectionsUseCase getProfileSectionsUseCase, + required SignOutUseCase signOutUseCase, + }) : _getStaffProfileUseCase = getStaffProfileUseCase, + _getProfileSectionsUseCase = getProfileSectionsUseCase, + _signOutUseCase = signOutUseCase, + super(const ProfileState()); - final ProfileRepositoryImpl _repository; + final GetStaffProfileUseCase _getStaffProfileUseCase; + final GetProfileSectionsUseCase _getProfileSectionsUseCase; + final SignOutUseCase _signOutUseCase; /// Loads the staff member's profile. Future loadProfile() async { @@ -23,7 +34,7 @@ class ProfileCubit extends Cubit await handleError( emit: emit, action: () async { - final Staff profile = await _repository.getStaffProfile(); + final Staff profile = await _getStaffProfileUseCase(); emit(state.copyWith(status: ProfileStatus.loaded, profile: profile)); }, onError: (String errorKey) => @@ -37,7 +48,7 @@ class ProfileCubit extends Cubit emit: emit, action: () async { final ProfileSectionStatus sections = - await _repository.getProfileSections(); + await _getProfileSectionsUseCase(); emit(state.copyWith( personalInfoComplete: sections.personalInfoCompleted, emergencyContactsComplete: sections.emergencyContactCompleted, @@ -62,7 +73,7 @@ class ProfileCubit extends Cubit await handleError( emit: emit, action: () async { - await _repository.signOut(); + await _signOutUseCase(); emit(state.copyWith(status: ProfileStatus.signedOut)); }, onError: (String _) => diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/logout_button.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/logout_button.dart index d74e9655..11ffa4aa 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/logout_button.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/logout_button.dart @@ -19,7 +19,7 @@ class LogoutButton extends StatelessWidget { /// sign-out process via the ProfileCubit. void _handleSignOut(BuildContext context, ProfileState state) { if (state.status != ProfileStatus.loading) { - context.read().signOut(); + ReadContext(context).read().signOut(); } } @@ -47,7 +47,7 @@ class LogoutButton extends StatelessWidget { onTap: () { _handleSignOut( context, - context.read().state, + ReadContext(context).read().state, ); }, borderRadius: UiConstants.radiusLg, 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 index ff95bbbc..c14accd9 100644 --- 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 @@ -26,9 +26,9 @@ class MenuSectionSkeleton extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ // Section title placeholder - Padding( - padding: const EdgeInsets.only(left: UiConstants.space1), - child: const UiShimmerLine(width: 100, height: 12), + const Padding( + padding: EdgeInsets.only(left: UiConstants.space1), + child: UiShimmerLine(width: 100, height: 12), ), const SizedBox(height: UiConstants.space3), // Menu items grid 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 index 60ee0ac0..c42c0ffb 100644 --- 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 @@ -25,18 +25,18 @@ class ProfileHeaderSkeleton extends StatelessWidget { bottom: Radius.circular(UiConstants.space6), ), ), - child: SafeArea( + child: const SafeArea( bottom: false, child: Column( children: [ // Avatar placeholder - const UiShimmerCircle(size: 112), - const SizedBox(height: UiConstants.space4), + UiShimmerCircle(size: 112), + SizedBox(height: UiConstants.space4), // Name placeholder - const UiShimmerLine(width: 160, height: 20), - const SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 160, height: 20), + SizedBox(height: UiConstants.space2), // Level badge placeholder - const UiShimmerBox(width: 100, height: 24), + 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 index 162a61e6..d4505ff3 100644 --- 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 @@ -26,13 +26,11 @@ class ProfilePageSkeleton extends StatelessWidget { // 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: const Padding( + padding: EdgeInsets.symmetric(horizontal: UiConstants.space5), child: Column( spacing: UiConstants.space6, - children: const [ + children: [ // Reliability stats row (5 items) ReliabilityStatsSkeleton(), 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 2b6f8f60..e9854ab8 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 @@ -4,13 +4,17 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:staff_profile/src/data/repositories/profile_repository_impl.dart'; +import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart'; +import 'package:staff_profile/src/domain/usecases/get_profile_sections_usecase.dart'; +import 'package:staff_profile/src/domain/usecases/get_staff_profile_usecase.dart'; +import 'package:staff_profile/src/domain/usecases/sign_out_usecase.dart'; import 'package:staff_profile/src/presentation/blocs/profile_cubit.dart'; import 'package:staff_profile/src/presentation/pages/staff_profile_page.dart'; /// The entry module for the Staff Profile feature. /// /// Uses the V2 REST API via [BaseApiService] for all backend access. -/// Section completion statuses are fetched in a single API call. +/// Registers repository interface, use cases, and cubit for DI. class StaffProfileModule extends Module { @override List get imports => [CoreModule()]; @@ -18,15 +22,36 @@ class StaffProfileModule extends Module { @override void binds(Injector i) { // Repository - i.addLazySingleton( + i.addLazySingleton( () => ProfileRepositoryImpl( apiService: i.get(), ), ); + // Use Cases + i.addLazySingleton( + () => GetStaffProfileUseCase( + i.get(), + ), + ); + i.addLazySingleton( + () => GetProfileSectionsUseCase( + i.get(), + ), + ); + i.addLazySingleton( + () => SignOutUseCase( + i.get(), + ), + ); + // Cubit i.addLazySingleton( - () => ProfileCubit(i.get()), + () => ProfileCubit( + getStaffProfileUseCase: i.get(), + getProfileSectionsUseCase: i.get(), + signOutUseCase: i.get(), + ), ); } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart index 7de2157e..011072bf 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart @@ -40,27 +40,42 @@ class CertificatesRepositoryImpl implements CertificatesRepository { Future uploadCertificate({ required String certificateType, required String name, - required String filePath, + String? filePath, + String? existingFileUri, DateTime? expiryDate, String? issuer, String? certificateNumber, }) async { - // 1. Upload the file to cloud storage - final FileUploadResponse uploadRes = await _uploadService.uploadFile( - filePath: filePath, - fileName: - 'staff_cert_${certificateType}_${DateTime.now().millisecondsSinceEpoch}.pdf', - visibility: FileVisibility.private, - ); + String fileUri; + String? signedUrl; - // 2. Generate a signed URL - final SignedUrlResponse signedUrlRes = - await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri); + if (filePath != null) { + // NEW FILE: Full upload pipeline + // 1. Upload the file to cloud storage + final FileUploadResponse uploadRes = await _uploadService.uploadFile( + filePath: filePath, + fileName: + 'staff_cert_${certificateType}_${DateTime.now().millisecondsSinceEpoch}.pdf', + visibility: FileVisibility.private, + ); - // 3. Initiate verification + // 2. Generate a signed URL + final SignedUrlResponse signedUrlRes = + await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri); + + fileUri = uploadRes.fileUri; + signedUrl = signedUrlRes.signedUrl; + } else if (existingFileUri != null) { + // EXISTING FILE: Metadata-only update — skip upload steps + fileUri = existingFileUri; + } else { + throw ArgumentError('Either filePath or existingFileUri must be provided'); + } + + // 3. Create verification (works for both new and existing files) final VerificationResponse verificationRes = await _verificationService.createVerification( - fileUri: uploadRes.fileUri, + fileUri: fileUri, type: 'certification', subjectType: 'worker', subjectId: certificateType, @@ -71,21 +86,21 @@ class CertificatesRepositoryImpl implements CertificatesRepository { }, ); - // 4. Save certificate via V2 API + // 4. Save/update certificate via V2 API (upserts on certificate_type) await _api.post( StaffEndpoints.certificates, data: { 'certificateType': certificateType, 'name': name, - 'fileUri': signedUrlRes.signedUrl, - 'expiresAt': expiryDate?.toIso8601String(), + if (signedUrl != null) 'fileUri': signedUrl, + 'expiresAt': expiryDate?.toUtc().toIso8601String(), 'issuer': issuer, 'certificateNumber': certificateNumber, 'verificationId': verificationRes.verificationId, }, ); - // 5. Return updated list + // 5. Return updated certificate final List certificates = await getCertificates(); return certificates.firstWhere( (StaffCertificate c) => c.certificateType == certificateType, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/repositories/certificates_repository.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/repositories/certificates_repository.dart index 93a85a47..9523a44b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/repositories/certificates_repository.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/repositories/certificates_repository.dart @@ -9,10 +9,15 @@ abstract interface class CertificatesRepository { Future> getCertificates(); /// Uploads a certificate file and saves the record. + /// + /// When [filePath] is provided, a new file is uploaded to cloud storage. + /// When only [existingFileUri] is provided, the existing stored file is + /// reused and only metadata (e.g. expiry date) is updated. Future uploadCertificate({ required String certificateType, required String name, - required String filePath, + String? filePath, + String? existingFileUri, DateTime? expiryDate, String? issuer, String? certificateNumber, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upload_certificate_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upload_certificate_usecase.dart index 1794ef37..eac321ad 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upload_certificate_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upload_certificate_usecase.dart @@ -15,6 +15,7 @@ class UploadCertificateUseCase certificateType: params.certificateType, name: params.name, filePath: params.filePath, + existingFileUri: params.existingFileUri, expiryDate: params.expiryDate, issuer: params.issuer, certificateNumber: params.certificateNumber, @@ -25,14 +26,21 @@ class UploadCertificateUseCase /// Parameters for [UploadCertificateUseCase]. class UploadCertificateParams { /// Creates [UploadCertificateParams]. + /// + /// Either [filePath] (for a new file upload) or [existingFileUri] (for a + /// metadata-only update using an already-stored file) must be provided. UploadCertificateParams({ required this.certificateType, required this.name, - required this.filePath, + this.filePath, + this.existingFileUri, this.expiryDate, this.issuer, this.certificateNumber, - }); + }) : assert( + filePath != null || existingFileUri != null, + 'Either filePath or existingFileUri must be provided', + ); /// The type of certification (e.g. "FOOD_HYGIENE", "SIA_BADGE"). final String certificateType; @@ -40,8 +48,12 @@ class UploadCertificateParams { /// The name of the certificate. final String name; - /// The local file path to upload. - final String filePath; + /// The local file path to upload, or null when reusing an existing file. + final String? filePath; + + /// The remote URI of an already-uploaded file, used for metadata-only + /// updates (e.g. changing only the expiry date). + final String? existingFileUri; /// The expiry date of the certificate. final DateTime? expiryDate; diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart index 72a25abd..753e7b4c 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart @@ -43,6 +43,12 @@ class _CertificateUploadPageState extends State { super.initState(); _cubit = Modular.get(); + // Pre-populate file path with existing remote URI when editing so + // the form is valid without re-picking a file. + if (widget.certificate?.fileUri != null) { + _cubit.setSelectedFilePath(widget.certificate!.fileUri); + } + if (widget.certificate != null) { _selectedExpiryDate = widget.certificate!.expiresAt; _issuerController.text = widget.certificate!.issuer ?? ''; @@ -148,9 +154,7 @@ class _CertificateUploadPageState extends State { @override Widget build(BuildContext context) { return BlocProvider.value( - value: _cubit..setSelectedFilePath( - widget.certificate?.fileUri, - ), + value: _cubit, child: BlocConsumer( listener: (BuildContext context, CertificateUploadState state) { if (state.status == CertificateUploadStatus.success) { @@ -182,7 +186,8 @@ class _CertificateUploadPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ PdfFileTypesBanner( - message: t.staff_documents.upload.pdf_banner, + title: t.staff_documents.upload.pdf_banner_title, + description: t.staff_documents.upload.pdf_banner_description, ), const SizedBox(height: UiConstants.space6), @@ -222,18 +227,27 @@ class _CertificateUploadPageState extends State { padding: const EdgeInsets.all(UiConstants.space5), child: CertificateUploadActions( isAttested: state.isAttested, - isFormValid: state.selectedFilePath != null && + isFormValid: (state.selectedFilePath != null || + widget.certificate?.fileUri != null) && state.isAttested && _nameController.text.isNotEmpty, isUploading: state.status == CertificateUploadStatus.uploading, hasExistingCertificate: widget.certificate != null, onUploadPressed: () { + final String? selectedPath = state.selectedFilePath; + final bool isLocalFile = selectedPath != null && + !selectedPath.startsWith('http') && + !selectedPath.startsWith('gs://'); + BlocProvider.of(context) .uploadCertificate( UploadCertificateParams( certificateType: _selectedType, name: _nameController.text, - filePath: state.selectedFilePath!, + filePath: isLocalFile ? selectedPath : null, + existingFileUri: !isLocalFile + ? (selectedPath ?? widget.certificate?.fileUri) + : null, expiryDate: _selectedExpiryDate, issuer: _issuerController.text, certificateNumber: _numberController.text, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/certificate_metadata_fields.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/certificate_metadata_fields.dart index e8849918..15d7d5ee 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/certificate_metadata_fields.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/certificate_metadata_fields.dart @@ -5,6 +5,7 @@ import 'package:core_localization/core_localization.dart'; /// Widget for certificate metadata input fields (name, issuer, number). class CertificateMetadataFields extends StatelessWidget { const CertificateMetadataFields({ + super.key, required this.nameController, required this.issuerController, required this.numberController, @@ -32,9 +33,7 @@ class CertificateMetadataFields extends StatelessWidget { enabled: isNewCertificate, decoration: InputDecoration( hintText: t.staff_certificates.upload_modal.name_hint, - border: OutlineInputBorder( - borderRadius: UiConstants.radiusLg, - ), + border: OutlineInputBorder(borderRadius: UiConstants.radiusLg), ), ), const SizedBox(height: UiConstants.space4), @@ -50,27 +49,20 @@ class CertificateMetadataFields extends StatelessWidget { enabled: isNewCertificate, decoration: InputDecoration( hintText: t.staff_certificates.upload_modal.issuer_hint, - border: OutlineInputBorder( - borderRadius: UiConstants.radiusLg, - ), + border: OutlineInputBorder(borderRadius: UiConstants.radiusLg), ), ), const SizedBox(height: UiConstants.space4), // Certificate Number Field - Text( - 'Certificate Number', - style: UiTypography.body2m.textPrimary, - ), + Text('Certificate Number', style: UiTypography.body2m.textPrimary), const SizedBox(height: UiConstants.space2), TextField( controller: numberController, enabled: isNewCertificate, decoration: InputDecoration( hintText: 'Enter number if applicable', - border: OutlineInputBorder( - borderRadius: UiConstants.radiusLg, - ), + border: OutlineInputBorder(borderRadius: UiConstants.radiusLg), ), ), ], diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/certificate_upload_actions.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/certificate_upload_actions.dart index 3887e5df..e1f217a5 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/certificate_upload_actions.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/certificate_upload_actions.dart @@ -8,6 +8,7 @@ import '../../blocs/certificate_upload/certificate_upload_cubit.dart'; /// Widget for attestation checkbox and action buttons in certificate upload form. class CertificateUploadActions extends StatelessWidget { const CertificateUploadActions({ + super.key, required this.isAttested, required this.isFormValid, required this.isUploading, @@ -34,10 +35,9 @@ class CertificateUploadActions extends StatelessWidget { children: [ Checkbox( value: isAttested, - onChanged: (bool? val) => - BlocProvider.of(context).setAttested( - val ?? false, - ), + onChanged: (bool? val) => BlocProvider.of( + context, + ).setAttested(val ?? false), activeColor: UiColors.primary, ), Expanded( @@ -54,17 +54,11 @@ class CertificateUploadActions extends StatelessWidget { onPressed: isFormValid ? onUploadPressed : null, style: ElevatedButton.styleFrom( backgroundColor: UiColors.primary, - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space4, - ), - shape: RoundedRectangleBorder( - borderRadius: UiConstants.radiusLg, - ), + padding: const EdgeInsets.symmetric(vertical: UiConstants.space4), + shape: RoundedRectangleBorder(borderRadius: UiConstants.radiusLg), ), child: isUploading - ? const CircularProgressIndicator( - color: Colors.white, - ) + ? const CircularProgressIndicator(color: Colors.white) : Text( t.staff_certificates.upload_modal.save, style: UiTypography.body1m.white, @@ -87,9 +81,7 @@ class CertificateUploadActions extends StatelessWidget { ), shape: RoundedRectangleBorder( borderRadius: UiConstants.radiusLg, - side: const BorderSide( - color: UiColors.destructive, - ), + side: const BorderSide(color: UiColors.destructive), ), ), ), diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/expiry_date_field.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/expiry_date_field.dart index 43fd484d..bf756db1 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/expiry_date_field.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/expiry_date_field.dart @@ -6,6 +6,7 @@ import 'package:core_localization/core_localization.dart'; /// Widget for selecting certificate expiry date. class ExpiryDateField extends StatelessWidget { const ExpiryDateField({ + super.key, required this.selectedDate, required this.onTap, }); diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/pdf_file_types_banner.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/pdf_file_types_banner.dart index 9aeb971f..4a704e0b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/pdf_file_types_banner.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/pdf_file_types_banner.dart @@ -3,12 +3,24 @@ import 'package:flutter/material.dart'; /// Banner displaying accepted file types and size limit for PDF upload. class PdfFileTypesBanner extends StatelessWidget { - const PdfFileTypesBanner({super.key, required this.message}); + const PdfFileTypesBanner({ + super.key, + required this.title, + this.description, + }); - final String message; + /// Short title for the banner. + final String title; + + /// Optional description with additional details. + final String? description; @override Widget build(BuildContext context) { - return UiNoticeBanner(title: message, icon: UiIcons.info); + return UiNoticeBanner( + title: title, + description: description, + icon: UiIcons.info, + ); } } 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 index 7e41aad5..61c14d98 100644 --- 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 @@ -12,23 +12,23 @@ class CertificatesHeaderSkeleton extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.all(UiConstants.space5), decoration: const BoxDecoration(color: UiColors.primary), - child: SafeArea( + child: const SafeArea( bottom: false, child: Column( children: [ - const SizedBox(height: UiConstants.space4), - const UiShimmerCircle(size: 64), - const SizedBox(height: UiConstants.space3), + SizedBox(height: UiConstants.space4), + UiShimmerCircle(size: 64), + SizedBox(height: UiConstants.space3), UiShimmerLine( width: 120, height: 14, ), - const SizedBox(height: UiConstants.space2), + SizedBox(height: UiConstants.space2), UiShimmerLine( width: 80, height: 12, ), - const SizedBox(height: UiConstants.space6), + SizedBox(height: UiConstants.space6), ], ), ), diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/document_upload_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/document_upload_page.dart index 77b80fc7..4e8761c0 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/document_upload_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/document_upload_page.dart @@ -70,7 +70,8 @@ class DocumentUploadPage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ PdfFileTypesBanner( - message: t.staff_documents.upload.pdf_banner, + title: t.staff_documents.upload.pdf_banner_title, + description: t.staff_documents.upload.pdf_banner_description, ), const SizedBox(height: UiConstants.space6), DocumentFileSelector( diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/pdf_file_types_banner.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/pdf_file_types_banner.dart index 6c6dabfe..4a704e0b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/pdf_file_types_banner.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/pdf_file_types_banner.dart @@ -3,12 +3,24 @@ import 'package:flutter/material.dart'; /// Banner displaying accepted file types and size limit for PDF upload. class PdfFileTypesBanner extends StatelessWidget { - const PdfFileTypesBanner({required this.message, super.key}); + const PdfFileTypesBanner({ + super.key, + required this.title, + this.description, + }); - final String message; + /// Short title for the banner. + final String title; + + /// Optional description with additional details. + final String? description; @override Widget build(BuildContext context) { - return UiNoticeBanner(title: message, icon: UiIcons.info); + return UiNoticeBanner( + title: title, + description: description, + icon: UiIcons.info, + ); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_i9_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_i9_page.dart index e36c6bcb..31fcdb30 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_i9_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_i9_page.dart @@ -121,14 +121,14 @@ class _FormI9PageState extends State { void _handleNext(BuildContext context, int currentStep) { if (currentStep < _steps.length - 1) { - context.read().nextStep(_steps.length); + ReadContext(context).read().nextStep(_steps.length); } else { - context.read().submit(); + ReadContext(context).read().submit(); } } void _handleBack(BuildContext context) { - context.read().previousStep(); + ReadContext(context).read().previousStep(); } @override @@ -178,8 +178,9 @@ class _FormI9PageState extends State { } }, builder: (BuildContext context, FormI9State state) { - if (state.status == FormI9Status.success) + if (state.status == FormI9Status.success) { return _buildSuccessView(i18n); + } return Scaffold( backgroundColor: UiColors.background, @@ -458,7 +459,7 @@ class _FormI9PageState extends State { i18n.fields.first_name, value: state.firstName, onChanged: (String val) => - context.read().firstNameChanged(val), + ReadContext(context).read().firstNameChanged(val), placeholder: i18n.fields.hints.first_name, ), ), @@ -468,7 +469,7 @@ class _FormI9PageState extends State { i18n.fields.last_name, value: state.lastName, onChanged: (String val) => - context.read().lastNameChanged(val), + ReadContext(context).read().lastNameChanged(val), placeholder: i18n.fields.hints.last_name, ), ), @@ -482,7 +483,7 @@ class _FormI9PageState extends State { i18n.fields.middle_initial, value: state.middleInitial, onChanged: (String val) => - context.read().middleInitialChanged(val), + ReadContext(context).read().middleInitialChanged(val), placeholder: i18n.fields.hints.middle_initial, ), ), @@ -493,7 +494,7 @@ class _FormI9PageState extends State { i18n.fields.other_last_names, value: state.otherLastNames, onChanged: (String val) => - context.read().otherLastNamesChanged(val), + ReadContext(context).read().otherLastNamesChanged(val), placeholder: i18n.fields.maiden_name, ), ), @@ -504,7 +505,7 @@ class _FormI9PageState extends State { i18n.fields.dob, value: state.dob, onChanged: (String val) => - context.read().dobChanged(val), + ReadContext(context).read().dobChanged(val), placeholder: i18n.fields.hints.dob, keyboardType: TextInputType.datetime, ), @@ -517,7 +518,7 @@ class _FormI9PageState extends State { onChanged: (String val) { String text = val.replaceAll(RegExp(r'\D'), ''); if (text.length > 9) text = text.substring(0, 9); - context.read().ssnChanged(text); + ReadContext(context).read().ssnChanged(text); }, ), const SizedBox(height: UiConstants.space4), @@ -525,7 +526,7 @@ class _FormI9PageState extends State { i18n.fields.email, value: state.email, onChanged: (String val) => - context.read().emailChanged(val), + ReadContext(context).read().emailChanged(val), keyboardType: TextInputType.emailAddress, placeholder: i18n.fields.hints.email, ), @@ -534,7 +535,7 @@ class _FormI9PageState extends State { i18n.fields.phone, value: state.phone, onChanged: (String val) => - context.read().phoneChanged(val), + ReadContext(context).read().phoneChanged(val), keyboardType: TextInputType.phone, placeholder: i18n.fields.hints.phone, ), @@ -553,7 +554,7 @@ class _FormI9PageState extends State { i18n.fields.address_long, value: state.address, onChanged: (String val) => - context.read().addressChanged(val), + ReadContext(context).read().addressChanged(val), placeholder: i18n.fields.hints.address, ), const SizedBox(height: UiConstants.space4), @@ -561,7 +562,7 @@ class _FormI9PageState extends State { i18n.fields.apt, value: state.aptNumber, onChanged: (String val) => - context.read().aptNumberChanged(val), + ReadContext(context).read().aptNumberChanged(val), placeholder: i18n.fields.hints.apt, ), const SizedBox(height: UiConstants.space4), @@ -573,7 +574,7 @@ class _FormI9PageState extends State { i18n.fields.city, value: state.city, onChanged: (String val) => - context.read().cityChanged(val), + ReadContext(context).read().cityChanged(val), placeholder: i18n.fields.hints.city, ), ), @@ -592,7 +593,7 @@ class _FormI9PageState extends State { DropdownButtonFormField( initialValue: state.state.isEmpty ? null : state.state, onChanged: (String? val) => - context.read().stateChanged(val ?? ''), + ReadContext(context).read().stateChanged(val ?? ''), items: _usStates.map((String stateAbbr) { return DropdownMenuItem( value: stateAbbr, @@ -625,7 +626,7 @@ class _FormI9PageState extends State { i18n.fields.zip, value: state.zipCode, onChanged: (String val) => - context.read().zipCodeChanged(val), + ReadContext(context).read().zipCodeChanged(val), placeholder: i18n.fields.hints.zip, keyboardType: TextInputType.number, ), @@ -659,7 +660,7 @@ class _FormI9PageState extends State { i18n.fields.uscis_number_label, value: state.uscisNumber, onChanged: (String val) => - context.read().uscisNumberChanged(val), + ReadContext(context).read().uscisNumberChanged(val), placeholder: i18n.fields.hints.uscis, ), ) @@ -717,7 +718,7 @@ class _FormI9PageState extends State { }) { final bool isSelected = state.citizenshipStatus == value; return GestureDetector( - onTap: () => context.read().citizenshipStatusChanged(value), + onTap: () => ReadContext(context).read().citizenshipStatusChanged(value), child: Container( padding: const EdgeInsets.all(UiConstants.space4), decoration: BoxDecoration( @@ -802,7 +803,7 @@ class _FormI9PageState extends State { CheckboxListTile( value: state.preparerUsed, onChanged: (bool? val) { - context.read().preparerUsedChanged(val ?? false); + ReadContext(context).read().preparerUsedChanged(val ?? false); }, contentPadding: EdgeInsets.zero, title: Text( @@ -836,7 +837,7 @@ class _FormI9PageState extends State { TextPosition(offset: state.signature.length), ), onChanged: (String val) => - context.read().signatureChanged(val), + ReadContext(context).read().signatureChanged(val), decoration: InputDecoration( hintText: i18n.fields.signature_hint, filled: true, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_w4_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_w4_page.dart index a0b28ea0..e1a17d49 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_w4_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_w4_page.dart @@ -111,14 +111,14 @@ class _FormW4PageState extends State { void _handleNext(BuildContext context, int currentStep) { if (currentStep < _steps.length - 1) { - context.read().nextStep(_steps.length); + ReadContext(context).read().nextStep(_steps.length); } else { - context.read().submit(); + ReadContext(context).read().submit(); } } void _handleBack(BuildContext context) { - context.read().previousStep(); + ReadContext(context).read().previousStep(); } int _totalCredits(FormW4State state) { @@ -180,8 +180,9 @@ class _FormW4PageState extends State { } }, builder: (BuildContext context, FormW4State state) { - if (state.status == FormW4Status.success) + if (state.status == FormW4Status.success) { return _buildSuccessView(i18n); + } return Scaffold( backgroundColor: UiColors.background, @@ -457,7 +458,7 @@ class _FormW4PageState extends State { i18n.fields.first_name, value: state.firstName, onChanged: (String val) => - context.read().firstNameChanged(val), + ReadContext(context).read().firstNameChanged(val), placeholder: i18n.fields.placeholder_john, ), ), @@ -467,7 +468,7 @@ class _FormW4PageState extends State { i18n.fields.last_name, value: state.lastName, onChanged: (String val) => - context.read().lastNameChanged(val), + ReadContext(context).read().lastNameChanged(val), placeholder: i18n.fields.placeholder_smith, ), ), @@ -482,7 +483,7 @@ class _FormW4PageState extends State { onChanged: (String val) { String text = val.replaceAll(RegExp(r'\D'), ''); if (text.length > 9) text = text.substring(0, 9); - context.read().ssnChanged(text); + ReadContext(context).read().ssnChanged(text); }, ), const SizedBox(height: UiConstants.space4), @@ -490,7 +491,7 @@ class _FormW4PageState extends State { i18n.fields.address, value: state.address, onChanged: (String val) => - context.read().addressChanged(val), + ReadContext(context).read().addressChanged(val), placeholder: i18n.fields.placeholder_address, ), const SizedBox(height: UiConstants.space4), @@ -498,7 +499,7 @@ class _FormW4PageState extends State { i18n.fields.city_state_zip, value: state.cityStateZip, onChanged: (String val) => - context.read().cityStateZipChanged(val), + ReadContext(context).read().cityStateZipChanged(val), placeholder: i18n.fields.placeholder_csz, ), ], @@ -556,7 +557,7 @@ class _FormW4PageState extends State { ) { final bool isSelected = state.filingStatus == value; return GestureDetector( - onTap: () => context.read().filingStatusChanged(value), + onTap: () => ReadContext(context).read().filingStatusChanged(value), child: Container( padding: const EdgeInsets.all(UiConstants.space4), decoration: BoxDecoration( @@ -640,7 +641,7 @@ class _FormW4PageState extends State { ), const SizedBox(height: UiConstants.space6), GestureDetector( - onTap: () => context.read().multipleJobsChanged( + onTap: () => ReadContext(context).read().multipleJobsChanged( !state.multipleJobs, ), child: Container( @@ -751,7 +752,7 @@ class _FormW4PageState extends State { i18n.fields.children_each, (FormW4State s) => s.qualifyingChildren, (int val) => - context.read().qualifyingChildrenChanged(val), + ReadContext(context).read().qualifyingChildrenChanged(val), ), const Padding( padding: EdgeInsets.symmetric(vertical: 16), @@ -764,7 +765,7 @@ class _FormW4PageState extends State { i18n.fields.other_each, (FormW4State s) => s.otherDependents, (int val) => - context.read().otherDependentsChanged(val), + ReadContext(context).read().otherDependentsChanged(val), ), ], ), @@ -880,7 +881,7 @@ class _FormW4PageState extends State { i18n.fields.other_income, value: state.otherIncome, onChanged: (String val) => - context.read().otherIncomeChanged(val), + ReadContext(context).read().otherIncomeChanged(val), placeholder: i18n.fields.hints.zero, keyboardType: TextInputType.number, ), @@ -896,7 +897,7 @@ class _FormW4PageState extends State { i18n.fields.deductions, value: state.deductions, onChanged: (String val) => - context.read().deductionsChanged(val), + ReadContext(context).read().deductionsChanged(val), placeholder: i18n.fields.hints.zero, keyboardType: TextInputType.number, ), @@ -912,7 +913,7 @@ class _FormW4PageState extends State { i18n.fields.extra_withholding, value: state.extraWithholding, onChanged: (String val) => - context.read().extraWithholdingChanged(val), + ReadContext(context).read().extraWithholdingChanged(val), placeholder: i18n.fields.hints.zero, keyboardType: TextInputType.number, ), @@ -995,7 +996,7 @@ class _FormW4PageState extends State { TextPosition(offset: state.signature.length), ), onChanged: (String val) => - context.read().signatureChanged(val), + ReadContext(context).read().signatureChanged(val), decoration: InputDecoration( hintText: i18n.fields.signature_hint, filled: true, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_page/progress_overview.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_page/progress_overview.dart index 86efd133..f511aa00 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_page/progress_overview.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_page/progress_overview.dart @@ -4,7 +4,7 @@ import 'package:krow_domain/krow_domain.dart'; /// Widget displaying the overall progress of tax form completion. class TaxFormsProgressOverview extends StatelessWidget { - const TaxFormsProgressOverview({required this.forms}); + const TaxFormsProgressOverview({super.key, required this.forms}); final List forms; diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_page/tax_form_status_badge.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_page/tax_form_status_badge.dart index 930229df..08886fc4 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_page/tax_form_status_badge.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_page/tax_form_status_badge.dart @@ -4,7 +4,7 @@ import 'package:krow_domain/krow_domain.dart'; /// Widget displaying status badge for a tax form. class TaxFormStatusBadge extends StatelessWidget { - const TaxFormStatusBadge({required this.status}); + const TaxFormStatusBadge({super.key, required this.status}); final TaxFormStatus status; diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/account_card.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/account_card.dart index 0c9fe05b..05ccad12 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/account_card.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/account_card.dart @@ -3,15 +3,10 @@ import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart'; class AccountCard extends StatelessWidget { + const AccountCard({super.key, required this.account, required this.strings}); final BankAccount account; final dynamic strings; - const AccountCard({ - super.key, - required this.account, - required this.strings, - }); - @override Widget build(BuildContext context) { final bool isPrimary = account.isPrimary; 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 77aecffc..66571764 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 @@ -1,45 +1,27 @@ -// 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: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:core_localization/core_localization.dart'; -import 'package:design_system/design_system.dart'; -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'; +import 'package:staff_time_card/src/presentation/blocs/time_card_bloc.dart'; +import 'package:staff_time_card/src/presentation/widgets/month_selector.dart'; +import 'package:staff_time_card/src/presentation/widgets/shift_history_list.dart'; +import 'package:staff_time_card/src/presentation/widgets/time_card_skeleton/time_card_skeleton.dart'; +import 'package:staff_time_card/src/presentation/widgets/time_card_summary.dart'; /// The main page for displaying the staff time card. -class TimeCardPage extends StatefulWidget { +class TimeCardPage extends StatelessWidget { + /// Creates a [TimeCardPage]. const TimeCardPage({super.key}); - @override - State createState() => _TimeCardPageState(); -} - -class _TimeCardPageState extends State { - late final TimeCardBloc _bloc; - - @override - void initState() { - super.initState(); - _bloc = Modular.get(); - _bloc.add(LoadTimeCards(DateTime.now())); - } - @override Widget build(BuildContext context) { final Translations t = Translations.of(context); - return BlocProvider.value( - value: _bloc, - child: Scaffold( - appBar: UiAppBar( - title: t.staff_time_card.title, - showBackButton: true, - ), - body: BlocConsumer( + return Scaffold( + appBar: UiAppBar(title: t.staff_time_card.title, showBackButton: true), + body: BlocProvider.value( + value: Modular.get()..add(LoadTimeCards(DateTime.now())), + child: BlocConsumer( listener: (BuildContext context, TimeCardState state) { if (state is TimeCardError) { UiSnackbar.show( @@ -75,22 +57,24 @@ class _TimeCardPageState extends State { children: [ MonthSelector( selectedDate: state.selectedMonth, - onPreviousMonth: () => _bloc.add( - ChangeMonth( - DateTime( - state.selectedMonth.year, - state.selectedMonth.month - 1, - ), - ), - ), - onNextMonth: () => _bloc.add( - ChangeMonth( - DateTime( - state.selectedMonth.year, - state.selectedMonth.month + 1, - ), - ), - ), + onPreviousMonth: () => + ReadContext(context).read().add( + ChangeMonth( + DateTime( + state.selectedMonth.year, + state.selectedMonth.month - 1, + ), + ), + ), + onNextMonth: () => + ReadContext(context).read().add( + ChangeMonth( + DateTime( + state.selectedMonth.year, + state.selectedMonth.month + 1, + ), + ), + ), ), const SizedBox(height: UiConstants.space6), TimeCardSummary( diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/shift_history_list.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/shift_history_list.dart index 4d9ffd0b..39856787 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/shift_history_list.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/shift_history_list.dart @@ -17,9 +17,7 @@ class ShiftHistoryList extends StatelessWidget { children: [ Text( t.staff_time_card.shift_history, - style: UiTypography.title2b.copyWith( - color: UiColors.textPrimary, - ), + style: UiTypography.title2b, ), const SizedBox(height: UiConstants.space3), if (timesheets.isEmpty) diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/timesheet_card.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/timesheet_card.dart index 9248f9db..9ddcf955 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/timesheet_card.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/timesheet_card.dart @@ -6,7 +6,6 @@ import 'package:krow_domain/krow_domain.dart'; /// A card widget displaying details of a single shift/timecard. class TimesheetCard extends StatelessWidget { - const TimesheetCard({super.key, required this.timesheet}); final TimeCardEntry timesheet; @@ -25,9 +24,10 @@ class TimesheetCard extends StatelessWidget { decoration: BoxDecoration( color: UiColors.bgPopup, borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border), + border: Border.all(color: UiColors.border, width: 0.5), ), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -60,20 +60,22 @@ class TimesheetCard extends StatelessWidget { if (timesheet.clockInAt != null && timesheet.clockOutAt != null) _IconText( icon: UiIcons.clock, - text: '${DateFormat('h:mm a').format(timesheet.clockInAt!)} - ${DateFormat('h:mm a').format(timesheet.clockOutAt!)}', + text: + '${DateFormat('h:mm a').format(timesheet.clockInAt!)} - ${DateFormat('h:mm a').format(timesheet.clockOutAt!)}', ), if (timesheet.location != null) _IconText(icon: UiIcons.mapPin, text: timesheet.location!), ], ), - const SizedBox(height: UiConstants.space3), + const SizedBox(height: UiConstants.space5), Container( - padding: const EdgeInsets.only(top: UiConstants.space3), + padding: const EdgeInsets.only(top: UiConstants.space4), decoration: const BoxDecoration( - border: Border(top: BorderSide(color: UiColors.border)), + border: Border(top: BorderSide(color: UiColors.border, width: 0.5)), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( '${totalHours.toStringAsFixed(1)} ${t.staff_time_card.hours} @ \$${hourlyRate.toStringAsFixed(2)}${t.staff_time_card.per_hr}', @@ -81,7 +83,7 @@ class TimesheetCard extends StatelessWidget { ), Text( '\$${totalPay.toStringAsFixed(2)}', - style: UiTypography.title2b.primary, + style: UiTypography.title1b, ), ], ), @@ -93,7 +95,6 @@ class TimesheetCard extends StatelessWidget { } class _IconText extends StatelessWidget { - const _IconText({required this.icon, required this.text}); final IconData icon; final String text; @@ -105,10 +106,7 @@ class _IconText extends StatelessWidget { children: [ Icon(icon, size: 14, color: UiColors.iconSecondary), const SizedBox(width: UiConstants.space1), - Text( - text, - style: UiTypography.body2r.textSecondary, - ), + Text(text, style: UiTypography.body2r.textSecondary), ], ); } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/staff_time_card_module.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/staff_time_card_module.dart index c0bae901..c9dab3d7 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/staff_time_card_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/staff_time_card_module.dart @@ -30,7 +30,7 @@ class StaffTimeCardModule extends Module { ); // UseCases - i.add(GetTimeCardsUseCase.new); + i.addLazySingleton(GetTimeCardsUseCase.new); // Blocs i.add(TimeCardBloc.new); diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_add_button.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_add_button.dart index 769c709b..20c92a8d 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_add_button.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_add_button.dart @@ -11,7 +11,7 @@ class EmergencyContactAddButton extends StatelessWidget { return Center( child: TextButton.icon( onPressed: () => - context.read().add(EmergencyContactAdded()), + ReadContext(context).read().add(EmergencyContactAdded()), icon: const Icon(UiIcons.add, size: 20.0), label: Text( 'Add Another Contact', diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_form_item.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_form_item.dart index 9a326905..2f64b415 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_form_item.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_form_item.dart @@ -44,7 +44,7 @@ class EmergencyContactFormItem extends StatelessWidget { initialValue: contact.fullName, hint: 'Contact name', icon: UiIcons.user, - onChanged: (val) => context.read().add( + onChanged: (val) => ReadContext(context).read().add( EmergencyContactUpdated(index, contact.copyWith(fullName: val)), ), ), @@ -54,7 +54,7 @@ class EmergencyContactFormItem extends StatelessWidget { initialValue: contact.phone, hint: '+1 (555) 000-0000', icon: UiIcons.phone, - onChanged: (val) => context.read().add( + onChanged: (val) => ReadContext(context).read().add( EmergencyContactUpdated(index, contact.copyWith(phone: val)), ), ), @@ -66,7 +66,7 @@ class EmergencyContactFormItem extends StatelessWidget { items: _kRelationshipTypes, onChanged: (val) { if (val != null) { - context.read().add( + ReadContext(context).read().add( EmergencyContactUpdated( index, contact.copyWith(relationshipType: val), @@ -144,7 +144,7 @@ class EmergencyContactFormItem extends StatelessWidget { color: UiColors.textError, size: 20.0, ), - onPressed: () => context.read().add( + onPressed: () => ReadContext(context).read().add( EmergencyContactRemoved(index), ), ), diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/tappable_row.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/tappable_row.dart index 6d92aa9e..1a9df919 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/tappable_row.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/tappable_row.dart @@ -39,11 +39,11 @@ class TappableRow extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: UiConstants.space3, - children: [ + children: [ FieldLabel(text: i18n.locations_label), GestureDetector( onTap: enabled ? onTap : null, - child: Container( + child: SizedBox( width: double.infinity, child: Row( children: [ 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 66fa95ab..264e56ac 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 @@ -37,9 +37,9 @@ class _FaqsWidgetState extends State { void _onSearchChanged(String value) { if (value.isEmpty) { - context.read().add(const FetchFaqsEvent()); + ReadContext(context).read().add(const FetchFaqsEvent()); } else { - context.read().add(SearchFaqsEvent(query: value)); + ReadContext(context).read().add(SearchFaqsEvent(query: value)); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/privacy/privacy_section_widget.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/privacy/privacy_section_widget.dart index 215a103c..53afbbe8 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/privacy/privacy_section_widget.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/privacy/privacy_section_widget.dart @@ -23,7 +23,7 @@ class PrivacySectionWidget extends StatelessWidget { type: UiSnackbarType.success, ); // Clear the flag after showing the snackbar - context.read().add( + ReadContext(context).read().add( const ClearProfileVisibilityUpdatedEvent(), ); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart index 8835d825..6f474dfd 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart @@ -147,6 +147,16 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { ); } + @override + Future submitForApproval(String shiftId, {String? note}) async { + await _apiService.post( + StaffEndpoints.shiftSubmitForApproval(shiftId), + data: { + if (note != null) 'note': note, + }, + ); + } + @override Future getProfileCompletion() async { final ApiResponse response = diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart index abd6a9ea..d6583347 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart @@ -47,4 +47,9 @@ abstract interface class ShiftsRepositoryInterface { /// Returns whether the staff profile is complete. Future getProfileCompletion(); + + /// Submits a completed shift for timesheet approval. + /// + /// Only allowed for shifts in CHECKED_OUT or COMPLETED status. + Future submitForApproval(String shiftId, {String? note}); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/submit_for_approval_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/submit_for_approval_usecase.dart new file mode 100644 index 00000000..fbbf921e --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/submit_for_approval_usecase.dart @@ -0,0 +1,18 @@ +import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; + +/// Submits a completed shift for timesheet approval. +/// +/// Delegates to [ShiftsRepositoryInterface.submitForApproval] which calls +/// `POST /staff/shifts/:shiftId/submit-for-approval`. +class SubmitForApprovalUseCase { + /// Creates a [SubmitForApprovalUseCase]. + SubmitForApprovalUseCase(this.repository); + + /// The shifts repository. + final ShiftsRepositoryInterface repository; + + /// Executes the use case. + Future call(String shiftId, {String? note}) async { + return repository.submitForApproval(shiftId, note: note); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/utils/shift_date_utils.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/utils/shift_date_utils.dart new file mode 100644 index 00000000..a656c7ea --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/utils/shift_date_utils.dart @@ -0,0 +1,37 @@ +import 'package:krow_domain/krow_domain.dart'; + +/// Computes a Friday-based week calendar for the given [weekOffset]. +/// +/// Returns a list of 7 [DateTime] values starting from the Friday of the +/// week identified by [weekOffset] (0 = current week, negative = past, +/// positive = future). Each date is midnight-normalised. +List getCalendarDaysForOffset(int weekOffset) { + final DateTime now = DateTime.now(); + final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday; + final int daysSinceFriday = (reactDayIndex + 2) % 7; + final DateTime start = now + .subtract(Duration(days: daysSinceFriday)) + .add(Duration(days: weekOffset * 7)); + final DateTime startDate = DateTime(start.year, start.month, start.day); + return List.generate( + 7, + (int index) => startDate.add(Duration(days: index)), + ); +} + +/// Filters out [OpenShift] entries whose date is strictly before today. +/// +/// Comparison is done at midnight granularity so shifts scheduled for +/// today are always included. +List filterPastOpenShifts(List shifts) { + final DateTime now = DateTime.now(); + final DateTime today = DateTime(now.year, now.month, now.day); + return shifts.where((OpenShift shift) { + final DateTime dateOnly = DateTime( + shift.date.year, + shift.date.month, + shift.date.day, + ); + return !dateOnly.isBefore(today); + }).toList(); +} 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 3067440c..8dc53c57 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 @@ -53,7 +53,7 @@ class ShiftDetailsBloc extends Bloc isProfileComplete: isProfileComplete, )); } else { - emit(const ShiftDetailsError('Shift not found')); + emit(const ShiftDetailsError('errors.shift.not_found')); } }, onError: (String errorKey) => ShiftDetailsError(errorKey), @@ -74,7 +74,7 @@ class ShiftDetailsBloc extends Bloc ); emit( ShiftActionSuccess( - 'Shift successfully booked!', + 'shift_booked', shiftDate: event.date, ), ); @@ -91,7 +91,7 @@ class ShiftDetailsBloc extends Bloc emit: emit.call, action: () async { await declineShift(event.shiftId); - emit(const ShiftActionSuccess('Shift declined')); + emit(const ShiftActionSuccess('shift_declined_success')); }, onError: (String errorKey) => ShiftDetailsError(errorKey), ); diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart index 0d05ffa6..a63992b3 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart @@ -14,6 +14,8 @@ import 'package:staff_shifts/src/domain/usecases/get_history_shifts_usecase.dart import 'package:staff_shifts/src/domain/usecases/get_my_shifts_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_pending_assignments_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/submit_for_approval_usecase.dart'; +import 'package:staff_shifts/src/domain/utils/shift_date_utils.dart'; part 'shifts_event.dart'; part 'shifts_state.dart'; @@ -31,6 +33,7 @@ class ShiftsBloc extends Bloc required this.getProfileCompletion, required this.acceptShift, required this.declineShift, + required this.submitForApproval, }) : super(const ShiftsState()) { on(_onLoadShifts); on(_onLoadHistoryShifts); @@ -41,6 +44,7 @@ class ShiftsBloc extends Bloc on(_onCheckProfileCompletion); on(_onAcceptShift); on(_onDeclineShift); + on(_onSubmitForApproval); } /// Use case for assigned shifts. @@ -67,6 +71,9 @@ class ShiftsBloc extends Bloc /// Use case for declining a shift. final DeclineShiftUseCase declineShift; + /// Use case for submitting a shift for timesheet approval. + final SubmitForApprovalUseCase submitForApproval; + Future _onLoadShifts( LoadShiftsEvent event, Emitter emit, @@ -78,7 +85,7 @@ class ShiftsBloc extends Bloc await handleError( emit: emit.call, action: () async { - final List days = _getCalendarDaysForOffset(0); + final List days = getCalendarDaysForOffset(0); // Load assigned, pending, and cancelled shifts in parallel. final List results = await Future.wait(>[ @@ -110,6 +117,7 @@ class ShiftsBloc extends Bloc historyLoaded: false, myShiftsLoaded: true, searchQuery: '', + clearErrorMessage: true, ), ); }, @@ -136,6 +144,7 @@ class ShiftsBloc extends Bloc historyShifts: historyResult, historyLoading: false, historyLoaded: true, + clearErrorMessage: true, ), ); }, @@ -167,9 +176,10 @@ class ShiftsBloc extends Bloc ); emit( state.copyWith( - availableShifts: _filterPastOpenShifts(availableResult), + availableShifts: filterPastOpenShifts(availableResult), availableLoading: false, availableLoaded: true, + clearErrorMessage: true, ), ); }, @@ -219,9 +229,10 @@ class ShiftsBloc extends Bloc emit( state.copyWith( status: ShiftsStatus.loaded, - availableShifts: _filterPastOpenShifts(availableResult), + availableShifts: filterPastOpenShifts(availableResult), availableLoading: false, availableLoaded: true, + clearErrorMessage: true, ), ); }, @@ -239,6 +250,7 @@ class ShiftsBloc extends Bloc LoadShiftsForRangeEvent event, Emitter emit, ) async { + emit(state.copyWith(myShifts: const [], myShiftsLoaded: false)); await handleError( emit: emit.call, action: () async { @@ -251,6 +263,7 @@ class ShiftsBloc extends Bloc status: ShiftsStatus.loaded, myShifts: myShiftsResult, myShiftsLoaded: true, + clearErrorMessage: true, ), ); }, @@ -281,7 +294,7 @@ class ShiftsBloc extends Bloc emit( state.copyWith( - availableShifts: _filterPastOpenShifts(result), + availableShifts: filterPastOpenShifts(result), searchQuery: search, ), ); @@ -342,30 +355,37 @@ class ShiftsBloc extends Bloc ); } - /// Gets calendar days for the given week offset (Friday-based week). - List _getCalendarDaysForOffset(int weekOffset) { - final DateTime now = DateTime.now(); - final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday; - final int daysSinceFriday = (reactDayIndex + 2) % 7; - final DateTime start = now - .subtract(Duration(days: daysSinceFriday)) - .add(Duration(days: weekOffset * 7)); - final DateTime startDate = DateTime(start.year, start.month, start.day); - return List.generate( - 7, (int index) => startDate.add(Duration(days: index))); + Future _onSubmitForApproval( + SubmitForApprovalEvent event, + Emitter emit, + ) async { + // Guard: another submission is already in progress. + if (state.submittingShiftId != null) return; + // Guard: this shift was already submitted. + if (state.submittedShiftIds.contains(event.shiftId)) return; + + emit(state.copyWith(submittingShiftId: event.shiftId)); + await handleError( + emit: emit.call, + action: () async { + await submitForApproval(event.shiftId, note: event.note); + emit( + state.copyWith( + clearSubmittingShiftId: true, + clearErrorMessage: true, + submittedShiftIds: { + ...state.submittedShiftIds, + event.shiftId, + }, + ), + ); + }, + onError: (String errorKey) => state.copyWith( + clearSubmittingShiftId: true, + status: ShiftsStatus.error, + errorMessage: errorKey, + ), + ); } - /// Filters out open shifts whose date is in the past. - List _filterPastOpenShifts(List shifts) { - final DateTime now = DateTime.now(); - final DateTime today = DateTime(now.year, now.month, now.day); - return shifts.where((OpenShift shift) { - final DateTime dateOnly = DateTime( - shift.date.year, - shift.date.month, - shift.date.day, - ); - return !dateOnly.isBefore(today); - }).toList(); - } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart index ac14d74e..83a1d948 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart @@ -93,3 +93,18 @@ class CheckProfileCompletionEvent extends ShiftsEvent { @override List get props => []; } + +/// Submits a completed shift for timesheet approval. +class SubmitForApprovalEvent extends ShiftsEvent { + /// Creates a [SubmitForApprovalEvent]. + const SubmitForApprovalEvent({required this.shiftId, this.note}); + + /// The shift row id to submit. + final String shiftId; + + /// Optional note to include with the submission. + final String? note; + + @override + List get props => [shiftId, note]; +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart index 3b7a1de9..3906afc3 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart @@ -21,6 +21,8 @@ class ShiftsState extends Equatable { this.searchQuery = '', this.profileComplete, this.errorMessage, + this.submittingShiftId, + this.submittedShiftIds = const {}, }); /// Current lifecycle status. @@ -65,6 +67,12 @@ class ShiftsState extends Equatable { /// Error message key for display. final String? errorMessage; + /// The shift ID currently being submitted for approval (null when idle). + final String? submittingShiftId; + + /// Set of shift IDs that have been successfully submitted for approval. + final Set submittedShiftIds; + /// Creates a copy with the given fields replaced. ShiftsState copyWith({ ShiftsStatus? status, @@ -81,6 +89,10 @@ class ShiftsState extends Equatable { String? searchQuery, bool? profileComplete, String? errorMessage, + bool clearErrorMessage = false, + String? submittingShiftId, + bool clearSubmittingShiftId = false, + Set? submittedShiftIds, }) { return ShiftsState( status: status ?? this.status, @@ -96,7 +108,13 @@ class ShiftsState extends Equatable { myShiftsLoaded: myShiftsLoaded ?? this.myShiftsLoaded, searchQuery: searchQuery ?? this.searchQuery, profileComplete: profileComplete ?? this.profileComplete, - errorMessage: errorMessage ?? this.errorMessage, + errorMessage: clearErrorMessage + ? null + : (errorMessage ?? this.errorMessage), + submittingShiftId: clearSubmittingShiftId + ? null + : (submittingShiftId ?? this.submittingShiftId), + submittedShiftIds: submittedShiftIds ?? this.submittedShiftIds, ); } @@ -116,5 +134,7 @@ class ShiftsState extends Equatable { searchQuery, profileComplete, errorMessage, + submittingShiftId, + submittedShiftIds, ]; } 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 5eb65bc6..cb238376 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 @@ -47,12 +47,6 @@ class _ShiftDetailsPageState extends State { return DateFormat('EEEE, MMMM d, y').format(dt); } - double _calculateDuration(ShiftDetail detail) { - final int minutes = detail.endTime.difference(detail.startTime).inMinutes; - final double hours = minutes / 60; - return hours < 0 ? hours + 24 : hours; - } - @override Widget build(BuildContext context) { return BlocProvider( @@ -67,7 +61,7 @@ class _ShiftDetailsPageState extends State { _isApplying = false; UiSnackbar.show( context, - message: state.message, + message: _translateSuccessKey(context, state.message), type: UiSnackbarType.success, ); Modular.to.toShifts( @@ -98,14 +92,8 @@ class _ShiftDetailsPageState extends State { } final ShiftDetail detail = state.detail; - final dynamic i18n = - Translations.of(context).staff_shifts.shift_details; final bool isProfileComplete = state.isProfileComplete; - final double duration = _calculateDuration(detail); - final double hourlyRate = detail.hourlyRateCents / 100; - final double estimatedTotal = hourlyRate * duration; - return Scaffold( appBar: UiAppBar( centerTitle: false, @@ -122,45 +110,46 @@ class _ShiftDetailsPageState extends State { Padding( padding: const EdgeInsets.all(UiConstants.space6), child: UiNoticeBanner( - title: 'Complete Your Account', - description: - 'Complete your account to book this shift and start earning', + title: context.t.staff_shifts.shift_details + .complete_account_title, + description: context.t.staff_shifts.shift_details + .complete_account_description, icon: UiIcons.sparkles, ), ), ShiftDetailsHeader(detail: detail), const Divider(height: 1, thickness: 0.5), ShiftStatsRow( - estimatedTotal: estimatedTotal, - hourlyRate: hourlyRate, - duration: duration, - totalLabel: i18n.est_total, - hourlyRateLabel: i18n.hourly_rate, - hoursLabel: i18n.hours, + estimatedTotal: detail.estimatedTotal, + hourlyRate: detail.hourlyRate, + duration: detail.durationHours, + totalLabel: context.t.staff_shifts.shift_details.est_total, + hourlyRateLabel: context.t.staff_shifts.shift_details.hourly_rate, + hoursLabel: context.t.staff_shifts.shift_details.hours, ), const Divider(height: 1, thickness: 0.5), ShiftDateTimeSection( date: detail.date, startTime: detail.startTime, endTime: detail.endTime, - shiftDateLabel: i18n.shift_date, - clockInLabel: i18n.start_time, - clockOutLabel: i18n.end_time, + shiftDateLabel: context.t.staff_shifts.shift_details.shift_date, + clockInLabel: context.t.staff_shifts.shift_details.start_time, + clockOutLabel: context.t.staff_shifts.shift_details.end_time, ), const Divider(height: 1, thickness: 0.5), ShiftLocationSection( location: detail.location, address: detail.address ?? '', - locationLabel: i18n.location, - tbdLabel: i18n.tbd, - getDirectionLabel: i18n.get_direction, + locationLabel: context.t.staff_shifts.shift_details.location, + tbdLabel: context.t.staff_shifts.shift_details.tbd, + getDirectionLabel: context.t.staff_shifts.shift_details.get_direction, ), const Divider(height: 1, thickness: 0.5), if (detail.description != null && detail.description!.isNotEmpty) ShiftDescriptionSection( description: detail.description!, - descriptionLabel: i18n.job_description, + descriptionLabel: context.t.staff_shifts.shift_details.job_description, ), ], ), @@ -190,13 +179,11 @@ class _ShiftDetailsPageState extends State { } void _bookShift(BuildContext context, ShiftDetail detail) { - final dynamic i18n = - Translations.of(context).staff_shifts.shift_details.book_dialog; showDialog( context: context, builder: (BuildContext ctx) => AlertDialog( - title: Text(i18n.title as String), - content: Text(i18n.message as String), + title: Text(context.t.staff_shifts.shift_details.book_dialog.title), + content: Text(context.t.staff_shifts.shift_details.book_dialog.message), actions: [ TextButton( onPressed: () => Modular.to.popSafe(), @@ -228,14 +215,12 @@ class _ShiftDetailsPageState extends State { if (_actionDialogOpen) return; _actionDialogOpen = true; _isApplying = true; - final dynamic i18n = - Translations.of(context).staff_shifts.shift_details.applying_dialog; showDialog( context: context, useRootNavigator: true, barrierDismissible: false, builder: (BuildContext ctx) => AlertDialog( - title: Text(i18n.title as String), + title: Text(context.t.staff_shifts.shift_details.applying_dialog.title), content: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -250,7 +235,7 @@ class _ShiftDetailsPageState extends State { style: UiTypography.body2b.textPrimary, textAlign: TextAlign.center, ), - const SizedBox(height: 6), + const SizedBox(height: UiConstants.space1), Text( '${_formatDate(detail.date)} \u2022 ${_formatTime(detail.startTime)} - ${_formatTime(detail.endTime)}', style: UiTypography.body3r.textSecondary, @@ -270,6 +255,18 @@ class _ShiftDetailsPageState extends State { _actionDialogOpen = false; } + /// Translates a success message key to a localized string. + String _translateSuccessKey(BuildContext context, String key) { + switch (key) { + case 'shift_booked': + return context.t.staff_shifts.shift_details.shift_booked; + case 'shift_declined_success': + return context.t.staff_shifts.shift_details.shift_declined_success; + default: + return key; + } + } + void _showEligibilityErrorDialog(BuildContext context) { showDialog( context: context, @@ -288,16 +285,16 @@ class _ShiftDetailsPageState extends State { ], ), content: Text( - 'You are missing required certifications or documents to claim this shift. Please upload them to continue.', + context.t.staff_shifts.shift_details.missing_certifications, style: UiTypography.body2r.textSecondary, ), actions: [ UiButton.secondary( - text: 'Cancel', + text: Translations.of(context).common.cancel, onPressed: () => Navigator.of(ctx).pop(), ), UiButton.primary( - text: 'Go to Certificates', + text: context.t.staff_shifts.shift_details.go_to_certificates, onPressed: () { 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 d18df6d0..bb0bc006 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 @@ -12,10 +12,15 @@ import 'package:staff_shifts/src/presentation/widgets/tabs/my_shifts_tab.dart'; import 'package:staff_shifts/src/presentation/widgets/tabs/find_shifts_tab.dart'; import 'package:staff_shifts/src/presentation/widgets/tabs/history_shifts_tab.dart'; +/// Tabbed page for browsing staff shifts (My Shifts, Find Work, History). +/// +/// Manages tab state locally and delegates data loading to [ShiftsBloc]. class ShiftsPage extends StatefulWidget { - final ShiftTabType? initialTab; - final DateTime? selectedDate; - final bool refreshAvailable; + /// Creates a [ShiftsPage]. + /// + /// [initialTab] selects the active tab on first render. + /// [selectedDate] pre-selects a calendar date in the My Shifts tab. + /// [refreshAvailable] triggers a forced reload of available shifts. const ShiftsPage({ super.key, this.initialTab, @@ -23,6 +28,15 @@ class ShiftsPage extends StatefulWidget { this.refreshAvailable = false, }); + /// The tab to display on initial render. Defaults to [ShiftTabType.find]. + final ShiftTabType? initialTab; + + /// Optional date to pre-select in the My Shifts calendar. + final DateTime? selectedDate; + + /// When true, forces a refresh of available shifts on load. + final bool refreshAvailable; + @override State createState() => _ShiftsPageState(); } @@ -251,6 +265,8 @@ class _ShiftsPageState extends State { pendingAssignments: pendingAssignments, cancelledShifts: cancelledShifts, initialDate: _selectedDate, + submittedShiftIds: state.submittedShiftIds, + submittingShiftId: state.submittingShiftId, ); case ShiftTabType.find: if (availableLoading) { @@ -264,7 +280,11 @@ class _ShiftsPageState extends State { if (historyLoading) { return const ShiftsPageSkeleton(); } - return HistoryShiftsTab(historyShifts: historyShifts); + return HistoryShiftsTab( + historyShifts: historyShifts, + submittedShiftIds: state.submittedShiftIds, + submittingShiftId: state.submittingShiftId, + ); } } @@ -278,89 +298,85 @@ class _ShiftsPageState extends State { }) { final isActive = _activeTab == type; return Expanded( - child: Semantics( - identifier: 'shift_tab_${type.name}', - label: label, - child: GestureDetector( - onTap: !enabled - ? null - : () { - setState(() => _activeTab = type); - if (type == ShiftTabType.history) { - _bloc.add(LoadHistoryShiftsEvent()); - } - if (type == ShiftTabType.find) { - _bloc.add(LoadAvailableShiftsEvent()); - } - }, - child: Container( - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space2, - horizontal: UiConstants.space2, - ), - decoration: BoxDecoration( - color: isActive - ? UiColors.white - : UiColors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - size: 14, - color: !enabled - ? UiColors.white.withValues(alpha: 0.5) - : isActive - ? UiColors.primary - : UiColors.white, + child: GestureDetector( + onTap: !enabled + ? null + : () { + setState(() => _activeTab = type); + if (type == ShiftTabType.history) { + _bloc.add(LoadHistoryShiftsEvent()); + } + if (type == ShiftTabType.find) { + _bloc.add(LoadAvailableShiftsEvent()); + } + }, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space2, + horizontal: UiConstants.space2, + ), + decoration: BoxDecoration( + color: isActive + ? UiColors.white + : UiColors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 14, + color: !enabled + ? UiColors.white.withValues(alpha: 0.5) + : isActive + ? UiColors.primary + : UiColors.white, + ), + const SizedBox(width: UiConstants.space1), + Flexible( + child: Text( + label, + style: + (isActive + ? UiTypography.body3m.copyWith( + color: UiColors.primary, + ) + : UiTypography.body3m.white) + .copyWith( + color: !enabled + ? UiColors.white.withValues(alpha: 0.5) + : null, + ), + overflow: TextOverflow.ellipsis, ), + ), + if (showCount) ...[ const SizedBox(width: UiConstants.space1), - Flexible( - child: Text( - label, - style: - (isActive - ? UiTypography.body3m.copyWith( - color: UiColors.primary, - ) - : UiTypography.body3m.white) - .copyWith( - color: !enabled - ? UiColors.white.withValues(alpha: 0.5) - : null, - ), - overflow: TextOverflow.ellipsis, + Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space1, + vertical: 2, ), - ), - if (showCount) ...[ - const SizedBox(width: 4), - Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space1, - vertical: 2, - ), - constraints: const BoxConstraints(minWidth: 18), - decoration: BoxDecoration( - color: isActive - ? UiColors.primary.withValues(alpha: 0.1) - : UiColors.white.withValues(alpha: 0.2), - borderRadius: UiConstants.radiusFull, - ), - child: Center( - child: Text( - "$count", - style: UiTypography.footnote1b.copyWith( - color: isActive ? UiColors.primary : UiColors.white, - ), + constraints: const BoxConstraints(minWidth: 18), + decoration: BoxDecoration( + color: isActive + ? UiColors.primary.withValues(alpha: 0.1) + : UiColors.white.withValues(alpha: 0.2), + borderRadius: UiConstants.radiusFull, + ), + child: Center( + child: Text( + "$count", + style: UiTypography.footnote1b.copyWith( + color: isActive ? UiColors.primary : UiColors.white, ), ), ), - ], + ), ], - ), + ], ), ), ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_assignment_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_assignment_card.dart index 5482707f..1b1cc12e 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_assignment_card.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_assignment_card.dart @@ -1,3 +1,4 @@ +import 'package:core_localization/core_localization.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -107,7 +108,7 @@ class ShiftAssignmentCard extends StatelessWidget { children: [ const Icon(UiIcons.calendar, size: 12, color: UiColors.iconSecondary), - const SizedBox(width: 4), + const SizedBox(width: UiConstants.space1), Text( _formatDate(assignment.startTime), style: UiTypography.footnote1r.textSecondary, @@ -115,19 +116,19 @@ class ShiftAssignmentCard extends StatelessWidget { const SizedBox(width: UiConstants.space3), const Icon(UiIcons.clock, size: 12, color: UiColors.iconSecondary), - const SizedBox(width: 4), + const SizedBox(width: UiConstants.space1), Text( '${_formatTime(assignment.startTime)} - ${_formatTime(assignment.endTime)}', style: UiTypography.footnote1r.textSecondary, ), ], ), - const SizedBox(height: 4), + const SizedBox(height: UiConstants.space1), Row( children: [ const Icon(UiIcons.mapPin, size: 12, color: UiColors.iconSecondary), - const SizedBox(width: 4), + const SizedBox(width: UiConstants.space1), Expanded( child: Text( assignment.location, @@ -160,7 +161,10 @@ class ShiftAssignmentCard extends StatelessWidget { style: TextButton.styleFrom( foregroundColor: UiColors.destructive, ), - child: Text('Decline', style: UiTypography.body2m.textError), + child: Text( + context.t.staff_shifts.shift_details.decline, + style: UiTypography.body2m.textError, + ), ), ), const SizedBox(width: UiConstants.space2), @@ -178,14 +182,17 @@ class ShiftAssignmentCard extends StatelessWidget { ), child: isConfirming ? const SizedBox( - height: 16, - width: 16, + height: UiConstants.space4, + width: UiConstants.space4, child: CircularProgressIndicator( strokeWidth: 2, color: UiColors.white, ), ) - : Text('Accept', style: UiTypography.body2m.white), + : Text( + context.t.staff_shifts.shift_details.accept_shift, + style: UiTypography.body2m.white, + ), ), ), ], diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card.dart deleted file mode 100644 index 63b83e93..00000000 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card.dart +++ /dev/null @@ -1,775 +0,0 @@ -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 'package:krow_domain/krow_domain.dart'; - -/// Variant that controls the visual treatment of the [ShiftCard]. -/// -/// Each variant maps to a different colour scheme for the status badge and -/// optional footer action area. -enum ShiftCardVariant { - /// Confirmed / accepted assignment. - confirmed, - - /// Pending assignment awaiting acceptance. - pending, - - /// Cancelled assignment. - cancelled, - - /// Completed shift (history). - completed, - - /// Worker is currently checked in. - checkedIn, - - /// A swap has been requested. - swapRequested, -} - -/// Immutable data model that feeds the [ShiftCard]. -/// -/// Acts as an adapter between the various shift entity types -/// (`AssignedShift`, `CompletedShift`, `CancelledShift`, `PendingAssignment`) -/// and the unified card presentation. -class ShiftCardData { - /// Creates a [ShiftCardData]. - const ShiftCardData({ - required this.shiftId, - required this.title, - required this.location, - required this.date, - required this.variant, - this.subtitle, - this.startTime, - this.endTime, - this.hourlyRateCents, - this.orderType, - this.minutesWorked, - this.cancellationReason, - this.paymentStatus, - }); - - /// Constructs [ShiftCardData] from an [AssignedShift]. - factory ShiftCardData.fromAssigned(AssignedShift shift) { - return ShiftCardData( - shiftId: shift.shiftId, - title: shift.roleName, - subtitle: shift.location, - location: shift.location, - date: shift.date, - startTime: shift.startTime, - endTime: shift.endTime, - hourlyRateCents: shift.hourlyRateCents, - orderType: shift.orderType, - variant: _variantFromAssignmentStatus(shift.status), - ); - } - - /// Constructs [ShiftCardData] from a [CompletedShift]. - factory ShiftCardData.fromCompleted(CompletedShift shift) { - return ShiftCardData( - shiftId: shift.shiftId, - title: shift.title, - location: shift.location, - date: shift.date, - minutesWorked: shift.minutesWorked, - paymentStatus: shift.paymentStatus, - variant: ShiftCardVariant.completed, - ); - } - - /// Constructs [ShiftCardData] from a [CancelledShift]. - factory ShiftCardData.fromCancelled(CancelledShift shift) { - return ShiftCardData( - shiftId: shift.shiftId, - title: shift.title, - location: shift.location, - date: shift.date, - cancellationReason: shift.cancellationReason, - variant: ShiftCardVariant.cancelled, - ); - } - - /// Constructs [ShiftCardData] from a [PendingAssignment]. - factory ShiftCardData.fromPending(PendingAssignment assignment) { - return ShiftCardData( - shiftId: assignment.shiftId, - title: assignment.roleName, - subtitle: assignment.title.isNotEmpty ? assignment.title : null, - location: assignment.location, - date: assignment.startTime, - startTime: assignment.startTime, - endTime: assignment.endTime, - variant: ShiftCardVariant.pending, - ); - } - - /// The shift row id. - final String shiftId; - - /// Primary display title (role name or shift title). - final String title; - - /// Optional secondary text (e.g. location under the role name). - final String? subtitle; - - /// Human-readable location label. - final String location; - - /// The date of the shift. - final DateTime date; - - /// Scheduled start time (null for completed/cancelled). - final DateTime? startTime; - - /// Scheduled end time (null for completed/cancelled). - final DateTime? endTime; - - /// Hourly pay rate in cents (null when not applicable). - final int? hourlyRateCents; - - /// Order type (null for completed/cancelled). - final OrderType? orderType; - - /// Minutes worked (only for completed shifts). - final int? minutesWorked; - - /// Cancellation reason (only for cancelled shifts). - final String? cancellationReason; - - /// Payment processing status (only for completed shifts). - final PaymentStatus? paymentStatus; - - /// Visual variant for the card. - final ShiftCardVariant variant; - - static ShiftCardVariant _variantFromAssignmentStatus( - AssignmentStatus status, - ) { - switch (status) { - case AssignmentStatus.accepted: - return ShiftCardVariant.confirmed; - case AssignmentStatus.checkedIn: - return ShiftCardVariant.checkedIn; - case AssignmentStatus.swapRequested: - return ShiftCardVariant.swapRequested; - case AssignmentStatus.completed: - return ShiftCardVariant.completed; - case AssignmentStatus.cancelled: - return ShiftCardVariant.cancelled; - case AssignmentStatus.assigned: - return ShiftCardVariant.pending; - case AssignmentStatus.checkedOut: - case AssignmentStatus.noShow: - case AssignmentStatus.unknown: - return ShiftCardVariant.confirmed; - } - } -} - -/// Unified card widget for displaying shift information across all shift types. -/// -/// Replaces `MyShiftCard`, `ShiftAssignmentCard`, and the inline -/// `_CompletedShiftCard` / `_buildCancelledCard` from the tabs. Accepts a -/// [ShiftCardData] data model that adapts the various domain entities into a -/// common display shape. -class ShiftCard extends StatelessWidget { - /// Creates a [ShiftCard]. - const ShiftCard({ - super.key, - required this.data, - this.onTap, - this.onSubmitForApproval, - this.showApprovalAction = false, - this.isSubmitted = false, - this.onAccept, - this.onDecline, - this.isAccepting = false, - }); - - /// The shift data to display. - final ShiftCardData data; - - /// Callback when the card is tapped (typically navigates to shift details). - final VoidCallback? onTap; - - /// Callback when the "Submit for Approval" button is pressed. - final VoidCallback? onSubmitForApproval; - - /// Whether to show the submit-for-approval footer. - final bool showApprovalAction; - - /// Whether the timesheet has already been submitted. - final bool isSubmitted; - - /// Callback when the accept action is pressed (pending assignments only). - final VoidCallback? onAccept; - - /// Callback when the decline action is pressed (pending assignments only). - final VoidCallback? onDecline; - - /// Whether the accept action is in progress. - final bool isAccepting; - - /// Whether the accept/decline footer should be shown. - bool get _showPendingActions => onAccept != null || onDecline != null; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border), - boxShadow: _showPendingActions - ? [ - BoxShadow( - color: UiColors.black.withValues(alpha: 0.05), - blurRadius: 2, - offset: const Offset(0, 1), - ), - ] - : null, - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(UiConstants.space4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _StatusBadge( - variant: data.variant, - orderType: data.orderType, - ), - const SizedBox(height: UiConstants.space2), - _CardBody(data: data), - if (showApprovalAction) ...[ - const SizedBox(height: UiConstants.space4), - const Divider(height: 1, color: UiColors.border), - const SizedBox(height: UiConstants.space2), - _ApprovalFooter( - isSubmitted: isSubmitted, - onSubmit: onSubmitForApproval, - ), - ], - ], - ), - ), - if (_showPendingActions) - _PendingActionsFooter( - onAccept: onAccept, - onDecline: onDecline, - isAccepting: isAccepting, - ), - ], - ), - ), - ); - } -} - -/// Displays the coloured status dot/icon and label, plus an optional order-type -/// chip. -class _StatusBadge extends StatelessWidget { - const _StatusBadge({required this.variant, this.orderType}); - - final ShiftCardVariant variant; - final OrderType? orderType; - - @override - Widget build(BuildContext context) { - final _StatusStyle style = _resolveStyle(context); - - return Row( - children: [ - if (style.icon != null) - Padding( - padding: const EdgeInsets.only(right: UiConstants.space2), - child: Icon( - style.icon, - size: UiConstants.iconXs, - color: style.foreground, - ), - ) - else - Container( - width: 8, - height: 8, - margin: const EdgeInsets.only(right: UiConstants.space2), - decoration: BoxDecoration( - color: style.dot, - shape: BoxShape.circle, - ), - ), - Text( - style.label, - style: UiTypography.footnote2b.copyWith( - color: style.foreground, - letterSpacing: 0.5, - ), - ), - if (orderType != null) ...[ - const SizedBox(width: UiConstants.space2), - _OrderTypeChip(orderType: orderType!), - ], - ], - ); - } - - _StatusStyle _resolveStyle(BuildContext context) { - switch (variant) { - case ShiftCardVariant.confirmed: - return _StatusStyle( - label: context.t.staff_shifts.status.confirmed, - foreground: UiColors.textLink, - dot: UiColors.primary, - ); - case ShiftCardVariant.pending: - return _StatusStyle( - label: context.t.staff_shifts.status.act_now, - foreground: UiColors.destructive, - dot: UiColors.destructive, - ); - case ShiftCardVariant.cancelled: - return _StatusStyle( - label: context.t.staff_shifts.my_shifts_tab.card.cancelled, - foreground: UiColors.destructive, - dot: UiColors.destructive, - ); - case ShiftCardVariant.completed: - return _StatusStyle( - label: context.t.staff_shifts.status.completed, - foreground: UiColors.textSuccess, - dot: UiColors.iconSuccess, - ); - case ShiftCardVariant.checkedIn: - return _StatusStyle( - label: context.t.staff_shifts.my_shift_card.checked_in, - foreground: UiColors.textSuccess, - dot: UiColors.iconSuccess, - ); - case ShiftCardVariant.swapRequested: - return _StatusStyle( - label: context.t.staff_shifts.status.swap_requested, - foreground: UiColors.textWarning, - dot: UiColors.textWarning, - icon: UiIcons.swap, - ); - } - } -} - -/// Internal helper grouping status badge presentation values. -class _StatusStyle { - const _StatusStyle({ - required this.label, - required this.foreground, - required this.dot, - this.icon, - }); - - final String label; - final Color foreground; - final Color dot; - final IconData? icon; -} - -/// Small chip showing the order type (One Day / Multi-Day / Long Term). -class _OrderTypeChip extends StatelessWidget { - const _OrderTypeChip({required this.orderType}); - - final OrderType orderType; - - @override - Widget build(BuildContext context) { - final String label = _label(context); - - return Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space2, - vertical: 2, - ), - decoration: BoxDecoration( - color: UiColors.background, - borderRadius: UiConstants.radiusSm, - border: Border.all(color: UiColors.border), - ), - child: Text( - label, - style: UiTypography.footnote2m.copyWith(color: UiColors.textSecondary), - ), - ); - } - - String _label(BuildContext context) { - switch (orderType) { - case OrderType.permanent: - return context.t.staff_shifts.filter.long_term; - case OrderType.recurring: - return context.t.staff_shifts.filter.multi_day; - case OrderType.oneTime: - case OrderType.rapid: - case OrderType.unknown: - return context.t.staff_shifts.filter.one_day; - } - } -} - -/// The main body: icon, title/subtitle, metadata rows, and optional pay info. -class _CardBody extends StatelessWidget { - const _CardBody({required this.data}); - - final ShiftCardData data; - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _ShiftIcon(variant: data.variant), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _TitleRow(data: data), - if (data.subtitle != null) ...[ - Text( - data.subtitle!, - style: UiTypography.body3r.textSecondary, - overflow: TextOverflow.ellipsis, - ), - ], - const SizedBox(height: UiConstants.space2), - _MetadataRows(data: data), - if (data.cancellationReason != null && - data.cancellationReason!.isNotEmpty) ...[ - const SizedBox(height: UiConstants.space1), - Text( - data.cancellationReason!, - style: UiTypography.footnote2r.textSecondary, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - ], - ); - } -} - -/// The 44x44 icon box with a gradient background. -class _ShiftIcon extends StatelessWidget { - const _ShiftIcon({required this.variant}); - - final ShiftCardVariant variant; - - @override - Widget build(BuildContext context) { - final bool isCancelled = variant == ShiftCardVariant.cancelled; - - return Container( - width: 44, - height: 44, - decoration: BoxDecoration( - gradient: isCancelled - ? null - : LinearGradient( - colors: [ - UiColors.primary.withValues(alpha: 0.09), - UiColors.primary.withValues(alpha: 0.03), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - color: isCancelled - ? UiColors.primary.withValues(alpha: 0.05) - : null, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: isCancelled - ? null - : Border.all( - color: UiColors.primary.withValues(alpha: 0.09), - ), - ), - child: const Center( - child: Icon( - UiIcons.briefcase, - color: UiColors.primary, - size: UiConstants.iconMd, - ), - ), - ); - } -} - -/// Title row with optional pay summary on the right. -class _TitleRow extends StatelessWidget { - const _TitleRow({required this.data}); - - final ShiftCardData data; - - @override - Widget build(BuildContext context) { - final bool hasPay = data.hourlyRateCents != null && - data.startTime != null && - data.endTime != null; - - if (!hasPay) { - return Text( - data.title, - style: UiTypography.body2m.textPrimary, - overflow: TextOverflow.ellipsis, - ); - } - - final double hourlyRate = data.hourlyRateCents! / 100; - final int durationMinutes = - data.endTime!.difference(data.startTime!).inMinutes; - double durationHours = durationMinutes / 60; - if (durationHours < 0) durationHours += 24; - durationHours = durationHours.roundToDouble(); - final double estimatedTotal = hourlyRate * durationHours; - - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - data.title, - style: UiTypography.body2m.textPrimary, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: UiConstants.space2), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '\$${estimatedTotal.toStringAsFixed(0)}', - style: UiTypography.title1m.textPrimary, - ), - Text( - '\$${hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h', - style: UiTypography.footnote2r.textSecondary, - ), - ], - ), - ], - ); - } -} - -/// Date, time, location, and worked-hours rows. -class _MetadataRows extends StatelessWidget { - const _MetadataRows({required this.data}); - - final ShiftCardData data; - - @override - Widget build(BuildContext context) { - return Column( - children: [ - // Date and time row - Row( - children: [ - const Icon( - UiIcons.calendar, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space1), - Text( - _formatDate(context, data.date), - style: UiTypography.footnote1r.textSecondary, - ), - if (data.startTime != null && data.endTime != null) ...[ - const SizedBox(width: UiConstants.space3), - const Icon( - UiIcons.clock, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space1), - Text( - '${_formatTime(data.startTime!)} - ${_formatTime(data.endTime!)}', - style: UiTypography.footnote1r.textSecondary, - ), - ], - if (data.minutesWorked != null) ...[ - const SizedBox(width: UiConstants.space3), - const Icon( - UiIcons.clock, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space1), - Text( - _formatWorkedDuration(data.minutesWorked!), - style: UiTypography.footnote1r.textSecondary, - ), - ], - ], - ), - const SizedBox(height: UiConstants.space1), - // Location row - Row( - children: [ - const Icon( - UiIcons.mapPin, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space1), - Expanded( - child: Text( - data.location, - style: UiTypography.footnote1r.textSecondary, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ], - ); - } - - String _formatDate(BuildContext context, DateTime date) { - final DateTime now = DateTime.now(); - final DateTime today = DateTime(now.year, now.month, now.day); - final DateTime tomorrow = today.add(const Duration(days: 1)); - final DateTime d = DateTime(date.year, date.month, date.day); - if (d == today) return context.t.staff_shifts.my_shifts_tab.date.today; - if (d == tomorrow) { - return context.t.staff_shifts.my_shifts_tab.date.tomorrow; - } - return DateFormat('EEE, MMM d').format(date); - } - - String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt); - - String _formatWorkedDuration(int totalMinutes) { - final int hours = totalMinutes ~/ 60; - final int mins = totalMinutes % 60; - return mins > 0 ? '${hours}h ${mins}m' : '${hours}h'; - } -} - -/// Footer showing the submit-for-approval action for completed shifts. -class _ApprovalFooter extends StatelessWidget { - const _ApprovalFooter({ - required this.isSubmitted, - this.onSubmit, - }); - - final bool isSubmitted; - final VoidCallback? onSubmit; - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - isSubmitted - ? context.t.staff_shifts.my_shift_card.submitted - : context.t.staff_shifts.my_shift_card.ready_to_submit, - style: UiTypography.footnote2b.copyWith( - color: isSubmitted ? UiColors.textSuccess : UiColors.textSecondary, - ), - ), - if (!isSubmitted) - UiButton.secondary( - text: context.t.staff_shifts.my_shift_card.submit_for_approval, - size: UiButtonSize.small, - onPressed: onSubmit, - ) - else - const Icon( - UiIcons.success, - color: UiColors.iconSuccess, - size: 20, - ), - ], - ); - } -} - -/// Coloured footer with Decline / Accept buttons for pending assignments. -class _PendingActionsFooter extends StatelessWidget { - const _PendingActionsFooter({ - this.onAccept, - this.onDecline, - this.isAccepting = false, - }); - - final VoidCallback? onAccept; - final VoidCallback? onDecline; - final bool isAccepting; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(UiConstants.space2), - decoration: const BoxDecoration( - color: UiColors.secondary, - borderRadius: BorderRadius.only( - bottomLeft: Radius.circular(UiConstants.radiusBase), - bottomRight: Radius.circular(UiConstants.radiusBase), - ), - ), - child: Row( - children: [ - Expanded( - child: TextButton( - onPressed: onDecline, - style: TextButton.styleFrom( - foregroundColor: UiColors.destructive, - ), - child: Text( - context.t.staff_shifts.action.decline, - style: UiTypography.body2m.textError, - ), - ), - ), - const SizedBox(width: UiConstants.space2), - Expanded( - child: ElevatedButton( - onPressed: isAccepting ? null : onAccept, - style: ElevatedButton.styleFrom( - backgroundColor: UiColors.primary, - foregroundColor: UiColors.white, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - UiConstants.radiusMdValue, - ), - ), - ), - child: isAccepting - ? const SizedBox( - height: 16, - width: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: UiColors.white, - ), - ) - : Text( - context.t.staff_shifts.action.confirm, - style: UiTypography.body2m.white, - ), - ), - ), - ], - ), - ); - } -} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/index.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/index.dart new file mode 100644 index 00000000..3c934a1a --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/index.dart @@ -0,0 +1,8 @@ +export 'shift_card.dart'; +export 'shift_card_approval_footer.dart'; +export 'shift_card_body.dart'; +export 'shift_card_data.dart'; +export 'shift_card_metadata_rows.dart'; +export 'shift_card_pending_footer.dart'; +export 'shift_card_status_badge.dart'; +export 'shift_card_title_row.dart'; diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card.dart new file mode 100644 index 00000000..eca3dd34 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card.dart @@ -0,0 +1,116 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_approval_footer.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_body.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_pending_footer.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_status_badge.dart'; + +/// Unified card widget for displaying shift information across all shift types. +/// +/// Replaces `MyShiftCard`, `ShiftAssignmentCard`, and the inline +/// `_CompletedShiftCard` / `_buildCancelledCard` from the tabs. Accepts a +/// [ShiftCardData] data model that adapts the various domain entities into a +/// common display shape. +class ShiftCard extends StatelessWidget { + /// Creates a [ShiftCard]. + const ShiftCard({ + super.key, + required this.data, + this.onTap, + this.onSubmitForApproval, + this.showApprovalAction = false, + this.isSubmitted = false, + this.isSubmitting = false, + this.onAccept, + this.onDecline, + this.isAccepting = false, + }); + + /// The shift data to display. + final ShiftCardData data; + + /// Callback when the card is tapped (typically navigates to shift details). + final VoidCallback? onTap; + + /// Callback when the "Submit for Approval" button is pressed. + final VoidCallback? onSubmitForApproval; + + /// Whether to show the submit-for-approval footer. + final bool showApprovalAction; + + /// Whether the timesheet has already been submitted. + final bool isSubmitted; + + /// Whether the timesheet submission is currently in progress. + final bool isSubmitting; + + /// Callback when the accept action is pressed (pending assignments only). + final VoidCallback? onAccept; + + /// Callback when the decline action is pressed (pending assignments only). + final VoidCallback? onDecline; + + /// Whether the accept action is in progress. + final bool isAccepting; + + /// Whether the accept/decline footer should be shown. + bool get _showPendingActions => onAccept != null || onDecline != null; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border, width: 0.5), + boxShadow: _showPendingActions + ? [ + BoxShadow( + color: UiColors.black.withValues(alpha: 0.05), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ] + : null, + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShiftCardStatusBadge( + variant: data.variant, + orderType: data.orderType, + ), + const SizedBox(height: UiConstants.space2), + ShiftCardBody(data: data), + if (showApprovalAction) ...[ + const SizedBox(height: UiConstants.space4), + const Divider(height: 1, color: UiColors.border), + const SizedBox(height: UiConstants.space2), + ShiftCardApprovalFooter( + isSubmitted: isSubmitted, + isSubmitting: isSubmitting, + onSubmit: onSubmitForApproval, + ), + ], + ], + ), + ), + if (_showPendingActions) + ShiftCardPendingActionsFooter( + onAccept: onAccept, + onDecline: onDecline, + isAccepting: isAccepting, + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_approval_footer.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_approval_footer.dart new file mode 100644 index 00000000..cff59051 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_approval_footer.dart @@ -0,0 +1,59 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Footer showing the submit-for-approval action for completed shifts. +class ShiftCardApprovalFooter extends StatelessWidget { + /// Creates a [ShiftCardApprovalFooter]. + const ShiftCardApprovalFooter({ + super.key, + required this.isSubmitted, + this.isSubmitting = false, + this.onSubmit, + }); + + /// Whether the timesheet has already been submitted. + final bool isSubmitted; + + /// Whether the submission is currently in progress. + final bool isSubmitting; + + /// Callback when the submit button is pressed. + final VoidCallback? onSubmit; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + isSubmitting + ? context.t.staff_shifts.my_shift_card.submitting + : isSubmitted + ? context.t.staff_shifts.my_shift_card.submitted + : context.t.staff_shifts.my_shift_card.ready_to_submit, + style: UiTypography.footnote2b.copyWith( + color: isSubmitted ? UiColors.textSuccess : UiColors.textSecondary, + ), + ), + if (isSubmitting) + const SizedBox( + height: UiConstants.space4, + width: UiConstants.space4, + child: CircularProgressIndicator( + strokeWidth: 2, + color: UiColors.primary, + ), + ) + else if (!isSubmitted) + UiButton.secondary( + text: context.t.staff_shifts.my_shift_card.submit_for_approval, + size: UiButtonSize.small, + onPressed: onSubmit, + ) + else + const Icon(UiIcons.success, color: UiColors.iconSuccess, size: 20), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart new file mode 100644 index 00000000..0816d430 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart @@ -0,0 +1,88 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_metadata_rows.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_title_row.dart'; + +/// The main body: icon, title/subtitle, metadata rows, and optional pay info. +class ShiftCardBody extends StatelessWidget { + /// Creates a [ShiftCardBody]. + const ShiftCardBody({super.key, required this.data}); + + /// The shift data to display. + final ShiftCardData data; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShiftCardIcon(variant: data.variant), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShiftCardTitleRow(data: data), + const SizedBox(height: UiConstants.space2), + ShiftCardMetadataRows(data: data), + if (data.cancellationReason != null && + data.cancellationReason!.isNotEmpty) ...[ + const SizedBox(height: UiConstants.space1), + Text( + data.cancellationReason!, + style: UiTypography.footnote2r.textSecondary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ); + } +} + +/// The 44x44 icon box with a gradient background. +class ShiftCardIcon extends StatelessWidget { + /// Creates a [ShiftCardIcon]. + const ShiftCardIcon({super.key, required this.variant}); + + /// The variant controlling the icon appearance. + final ShiftCardVariant variant; + + @override + Widget build(BuildContext context) { + final bool isCancelled = variant == ShiftCardVariant.cancelled; + + return Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: isCancelled + ? null + : LinearGradient( + colors: [ + UiColors.primary.withValues(alpha: 0.09), + UiColors.primary.withValues(alpha: 0.03), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + color: isCancelled ? UiColors.primary.withValues(alpha: 0.05) : null, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: isCancelled + ? null + : Border.all(color: UiColors.primary.withValues(alpha: 0.09)), + ), + child: const Center( + child: Icon( + UiIcons.briefcase, + color: UiColors.primary, + size: UiConstants.iconMd, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_data.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_data.dart new file mode 100644 index 00000000..626ff583 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_data.dart @@ -0,0 +1,180 @@ +import 'package:krow_domain/krow_domain.dart'; + +/// Variant that controls the visual treatment of the [ShiftCard]. +/// +/// Each variant maps to a different colour scheme for the status badge and +/// optional footer action area. +enum ShiftCardVariant { + /// Confirmed / accepted assignment. + confirmed, + + /// Pending assignment awaiting acceptance. + pending, + + /// Cancelled assignment. + cancelled, + + /// Completed shift (history). + completed, + + /// Worker is currently checked in. + checkedIn, + + /// A swap has been requested. + swapRequested, +} + +/// Immutable data model that feeds the [ShiftCard]. +/// +/// Acts as an adapter between the various shift entity types +/// (`AssignedShift`, `CompletedShift`, `CancelledShift`, `PendingAssignment`) +/// and the unified card presentation. +class ShiftCardData { + /// Creates a [ShiftCardData]. + const ShiftCardData({ + required this.shiftId, + required this.title, + required this.location, + required this.date, + required this.variant, + this.subtitle, + this.startTime, + this.endTime, + this.hourlyRateCents, + this.hourlyRate, + this.totalRate, + this.orderType, + this.minutesWorked, + this.cancellationReason, + this.paymentStatus, + }); + + /// Constructs [ShiftCardData] from an [AssignedShift]. + factory ShiftCardData.fromAssigned(AssignedShift shift) { + return ShiftCardData( + shiftId: shift.shiftId, + title: shift.roleName, + subtitle: shift.location, + location: shift.location, + date: shift.date, + startTime: shift.startTime, + endTime: shift.endTime, + hourlyRateCents: shift.hourlyRateCents, + orderType: shift.orderType, + variant: _variantFromAssignmentStatus(shift.status), + ); + } + + /// Constructs [ShiftCardData] from a [CompletedShift]. + factory ShiftCardData.fromCompleted(CompletedShift shift) { + return ShiftCardData( + shiftId: shift.shiftId, + title: shift.clientName.isNotEmpty ? shift.clientName : shift.title, + subtitle: shift.title.isNotEmpty ? shift.title : null, + location: shift.location, + date: shift.date, + startTime: shift.startTime, + endTime: shift.endTime, + hourlyRateCents: shift.hourlyRateCents, + hourlyRate: shift.hourlyRate, + totalRate: shift.totalRate, + minutesWorked: shift.minutesWorked, + paymentStatus: shift.paymentStatus, + variant: ShiftCardVariant.completed, + ); + } + + /// Constructs [ShiftCardData] from a [CancelledShift]. + factory ShiftCardData.fromCancelled(CancelledShift shift) { + return ShiftCardData( + shiftId: shift.shiftId, + title: shift.title, + location: shift.location, + date: shift.date, + cancellationReason: shift.cancellationReason, + variant: ShiftCardVariant.cancelled, + ); + } + + /// Constructs [ShiftCardData] from a [PendingAssignment]. + factory ShiftCardData.fromPending(PendingAssignment assignment) { + return ShiftCardData( + shiftId: assignment.shiftId, + title: assignment.roleName, + subtitle: assignment.title.isNotEmpty ? assignment.title : null, + location: assignment.location, + date: assignment.startTime, + startTime: assignment.startTime, + endTime: assignment.endTime, + variant: ShiftCardVariant.pending, + ); + } + + /// The shift row id. + final String shiftId; + + /// Primary display title (role name or shift title). + final String title; + + /// Optional secondary text (e.g. location under the role name). + final String? subtitle; + + /// Human-readable location label. + final String location; + + /// The date of the shift. + final DateTime date; + + /// Scheduled start time (null for completed/cancelled). + final DateTime? startTime; + + /// Scheduled end time (null for completed/cancelled). + final DateTime? endTime; + + /// Hourly pay rate in cents (null when not applicable). + final int? hourlyRateCents; + + /// Hourly pay rate in dollars (null when not applicable). + final double? hourlyRate; + + /// Total pay in dollars (null when not applicable). + final double? totalRate; + + /// Order type (null for completed/cancelled). + final OrderType? orderType; + + /// Minutes worked (only for completed shifts). + final int? minutesWorked; + + /// Cancellation reason (only for cancelled shifts). + final String? cancellationReason; + + /// Payment processing status (only for completed shifts). + final PaymentStatus? paymentStatus; + + /// Visual variant for the card. + final ShiftCardVariant variant; + + static ShiftCardVariant _variantFromAssignmentStatus( + AssignmentStatus status, + ) { + switch (status) { + case AssignmentStatus.accepted: + return ShiftCardVariant.confirmed; + case AssignmentStatus.checkedIn: + return ShiftCardVariant.checkedIn; + case AssignmentStatus.swapRequested: + return ShiftCardVariant.swapRequested; + case AssignmentStatus.completed: + return ShiftCardVariant.completed; + case AssignmentStatus.cancelled: + return ShiftCardVariant.cancelled; + case AssignmentStatus.assigned: + return ShiftCardVariant.pending; + case AssignmentStatus.checkedOut: + case AssignmentStatus.noShow: + case AssignmentStatus.unknown: + return ShiftCardVariant.confirmed; + } + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_metadata_rows.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_metadata_rows.dart new file mode 100644 index 00000000..df0ce572 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_metadata_rows.dart @@ -0,0 +1,102 @@ +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 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart'; + +/// Date, time, location, and worked-hours rows. +class ShiftCardMetadataRows extends StatelessWidget { + /// Creates a [ShiftCardMetadataRows]. + const ShiftCardMetadataRows({super.key, required this.data}); + + /// The shift data to display. + final ShiftCardData data; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Date and time row + Row( + children: [ + const Icon( + UiIcons.calendar, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Text( + _formatDate(context, data.date), + style: UiTypography.footnote1r.textSecondary, + ), + if (data.startTime != null && data.endTime != null) ...[ + const SizedBox(width: UiConstants.space3), + const Icon( + UiIcons.clock, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Text( + '${_formatTime(data.startTime!)} - ${_formatTime(data.endTime!)}', + style: UiTypography.footnote1r.textSecondary, + ), + ], + if (data.minutesWorked != null) ...[ + const SizedBox(width: UiConstants.space3), + const Icon( + UiIcons.clock, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Text( + _formatWorkedDuration(data.minutesWorked!), + style: UiTypography.footnote1r.textSecondary, + ), + ], + ], + ), + const SizedBox(height: UiConstants.space1), + // Location row + Row( + children: [ + const Icon( + UiIcons.mapPin, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Expanded( + child: Text( + data.location, + style: UiTypography.footnote1r.textSecondary, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ); + } + + String _formatDate(BuildContext context, DateTime date) { + final DateTime now = DateTime.now(); + final DateTime today = DateTime(now.year, now.month, now.day); + final DateTime tomorrow = today.add(const Duration(days: 1)); + final DateTime d = DateTime(date.year, date.month, date.day); + if (d == today) return context.t.staff_shifts.my_shifts_tab.date.today; + if (d == tomorrow) { + return context.t.staff_shifts.my_shifts_tab.date.tomorrow; + } + return DateFormat('EEE, MMM d').format(date); + } + + String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt); + + String _formatWorkedDuration(int totalMinutes) { + final int hours = totalMinutes ~/ 60; + final int mins = totalMinutes % 60; + return mins > 0 ? '${hours}h ${mins}m' : '${hours}h'; + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_pending_footer.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_pending_footer.dart new file mode 100644 index 00000000..dd9519c2 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_pending_footer.dart @@ -0,0 +1,82 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Coloured footer with Decline / Accept buttons for pending assignments. +class ShiftCardPendingActionsFooter extends StatelessWidget { + /// Creates a [ShiftCardPendingActionsFooter]. + const ShiftCardPendingActionsFooter({ + super.key, + this.onAccept, + this.onDecline, + this.isAccepting = false, + }); + + /// Callback when the accept action is pressed. + final VoidCallback? onAccept; + + /// Callback when the decline action is pressed. + final VoidCallback? onDecline; + + /// Whether the accept action is in progress. + final bool isAccepting; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space2), + decoration: const BoxDecoration( + color: UiColors.secondary, + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(UiConstants.radiusBase), + bottomRight: Radius.circular(UiConstants.radiusBase), + ), + ), + child: Row( + children: [ + Expanded( + child: TextButton( + onPressed: onDecline, + style: TextButton.styleFrom( + foregroundColor: UiColors.destructive, + ), + child: Text( + context.t.staff_shifts.action.decline, + style: UiTypography.body2m.textError, + ), + ), + ), + const SizedBox(width: UiConstants.space2), + Expanded( + child: ElevatedButton( + onPressed: isAccepting ? null : onAccept, + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.primary, + foregroundColor: UiColors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + UiConstants.radiusMdValue, + ), + ), + ), + child: isAccepting + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: UiColors.white, + ), + ) + : Text( + context.t.staff_shifts.action.confirm, + style: UiTypography.body2m.white, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_status_badge.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_status_badge.dart new file mode 100644 index 00000000..85465ea3 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_status_badge.dart @@ -0,0 +1,163 @@ +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 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart'; + +/// Displays the coloured status dot/icon and label, plus an optional order-type +/// chip. +class ShiftCardStatusBadge extends StatelessWidget { + /// Creates a [ShiftCardStatusBadge]. + const ShiftCardStatusBadge({super.key, required this.variant, this.orderType}); + + /// The visual variant for colour resolution. + final ShiftCardVariant variant; + + /// Optional order type shown as a trailing chip. + final OrderType? orderType; + + @override + Widget build(BuildContext context) { + final ShiftCardStatusStyle style = _resolveStyle(context); + + return Row( + children: [ + if (style.icon != null) + Padding( + padding: const EdgeInsets.only(right: UiConstants.space2), + child: Icon( + style.icon, + size: UiConstants.iconXs, + color: style.foreground, + ), + ) + else + Container( + width: 8, + height: 8, + margin: const EdgeInsets.only(right: UiConstants.space2), + decoration: BoxDecoration(color: style.dot, shape: BoxShape.circle), + ), + Text( + style.label, + style: UiTypography.footnote2b.copyWith( + color: style.foreground, + letterSpacing: 0.5, + ), + ), + if (orderType != null) ...[ + const SizedBox(width: UiConstants.space2), + ShiftCardOrderTypeChip(orderType: orderType!), + ], + ], + ); + } + + ShiftCardStatusStyle _resolveStyle(BuildContext context) { + switch (variant) { + case ShiftCardVariant.confirmed: + return ShiftCardStatusStyle( + label: context.t.staff_shifts.status.confirmed, + foreground: UiColors.textLink, + dot: UiColors.primary, + ); + case ShiftCardVariant.pending: + return ShiftCardStatusStyle( + label: context.t.staff_shifts.status.act_now, + foreground: UiColors.destructive, + dot: UiColors.destructive, + ); + case ShiftCardVariant.cancelled: + return ShiftCardStatusStyle( + label: context.t.staff_shifts.my_shifts_tab.card.cancelled, + foreground: UiColors.destructive, + dot: UiColors.destructive, + ); + case ShiftCardVariant.completed: + return ShiftCardStatusStyle( + label: context.t.staff_shifts.status.completed, + foreground: UiColors.textSuccess, + dot: UiColors.iconSuccess, + ); + case ShiftCardVariant.checkedIn: + return ShiftCardStatusStyle( + label: context.t.staff_shifts.my_shift_card.checked_in, + foreground: UiColors.textSuccess, + dot: UiColors.iconSuccess, + ); + case ShiftCardVariant.swapRequested: + return ShiftCardStatusStyle( + label: context.t.staff_shifts.status.swap_requested, + foreground: UiColors.textWarning, + dot: UiColors.textWarning, + icon: UiIcons.swap, + ); + } + } +} + +/// Helper grouping status badge presentation values. +class ShiftCardStatusStyle { + /// Creates a [ShiftCardStatusStyle]. + const ShiftCardStatusStyle({ + required this.label, + required this.foreground, + required this.dot, + this.icon, + }); + + /// The human-readable status label. + final String label; + + /// Foreground colour for the label and icon. + final Color foreground; + + /// Dot colour when no icon is provided. + final Color dot; + + /// Optional icon replacing the dot indicator. + final IconData? icon; +} + +/// Small chip showing the order type (One Day / Multi-Day / Long Term). +class ShiftCardOrderTypeChip extends StatelessWidget { + /// Creates a [ShiftCardOrderTypeChip]. + const ShiftCardOrderTypeChip({super.key, required this.orderType}); + + /// The order type to display. + final OrderType orderType; + + @override + Widget build(BuildContext context) { + final String label = _label(context); + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: UiColors.background, + borderRadius: UiConstants.radiusSm, + border: Border.all(color: UiColors.border), + ), + child: Text( + label, + style: UiTypography.footnote2m.copyWith(color: UiColors.textSecondary), + ), + ); + } + + String _label(BuildContext context) { + switch (orderType) { + case OrderType.permanent: + return context.t.staff_shifts.filter.long_term; + case OrderType.recurring: + return context.t.staff_shifts.filter.multi_day; + case OrderType.oneTime: + case OrderType.rapid: + case OrderType.unknown: + return context.t.staff_shifts.filter.one_day; + } + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_title_row.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_title_row.dart new file mode 100644 index 00000000..f6b18b07 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_title_row.dart @@ -0,0 +1,88 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart'; + +/// Title row with optional pay summary on the right. +class ShiftCardTitleRow extends StatelessWidget { + /// Creates a [ShiftCardTitleRow]. + const ShiftCardTitleRow({super.key, required this.data}); + + /// The shift data to display. + final ShiftCardData data; + + @override + Widget build(BuildContext context) { + final bool hasDirectRate = data.hourlyRate != null && data.hourlyRate! > 0; + final bool hasComputedRate = + data.hourlyRateCents != null && + data.startTime != null && + data.endTime != null; + + if (!hasDirectRate && !hasComputedRate) { + return Text( + data.title, + style: UiTypography.body2m.textPrimary, + overflow: TextOverflow.ellipsis, + ); + } + + // Prefer pre-computed values from the API when available. + final double hourlyRate; + final double estimatedTotal; + final double durationHours; + + if (hasDirectRate && data.totalRate != null && data.totalRate! > 0) { + hourlyRate = data.hourlyRate!; + estimatedTotal = data.totalRate!; + durationHours = hourlyRate > 0 ? (estimatedTotal / hourlyRate) : 0; + } else { + hourlyRate = data.hourlyRateCents! / 100; + final int durationMinutes = data.endTime! + .difference(data.startTime!) + .inMinutes; + double hours = durationMinutes / 60; + if (hours < 0) hours += 24; + durationHours = hours.roundToDouble(); + estimatedTotal = hourlyRate * durationHours; + } + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.title, + style: UiTypography.body2m.textPrimary, + overflow: TextOverflow.ellipsis, + ), + if (data.subtitle != null) ...[ + Text( + data.subtitle!, + style: UiTypography.body3r.textSecondary, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + const SizedBox(width: UiConstants.space3), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '\$${estimatedTotal.toStringAsFixed(0)}', + style: UiTypography.title1m.textPrimary, + ), + Text( + '\$${hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h', + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/history_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/history_shifts_tab.dart index 0ea3b6a6..5577fb24 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/history_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/history_shifts_tab.dart @@ -1,21 +1,34 @@ 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' show ReadContext; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart'; -import 'package:staff_shifts/src/presentation/widgets/shift_card.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_card/index.dart'; /// Tab displaying completed shift history. class HistoryShiftsTab extends StatelessWidget { /// Creates a [HistoryShiftsTab]. - const HistoryShiftsTab({super.key, required this.historyShifts}); + const HistoryShiftsTab({ + super.key, + required this.historyShifts, + this.submittedShiftIds = const {}, + this.submittingShiftId, + }); /// Completed shifts. final List historyShifts; + /// Set of shift IDs that have been successfully submitted for approval. + final Set submittedShiftIds; + + /// The shift ID currently being submitted (null when idle). + final String? submittingShiftId; + @override Widget build(BuildContext context) { if (historyShifts.isEmpty) { @@ -32,14 +45,34 @@ class HistoryShiftsTab extends StatelessWidget { children: [ const SizedBox(height: UiConstants.space5), ...historyShifts.map( - (CompletedShift shift) => Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space3), - child: ShiftCard( - data: ShiftCardData.fromCompleted(shift), - onTap: () => - Modular.to.toShiftDetailsById(shift.shiftId), - ), - ), + (CompletedShift shift) { + final bool isSubmitted = + submittedShiftIds.contains(shift.shiftId); + final bool isSubmitting = + submittingShiftId == shift.shiftId; + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: ShiftCard( + data: ShiftCardData.fromCompleted(shift), + onTap: () => + Modular.to.toShiftDetailsById(shift.shiftId), + showApprovalAction: !isSubmitted, + isSubmitted: isSubmitted, + isSubmitting: isSubmitting, + onSubmitForApproval: () { + ReadContext(context).read().add( + SubmitForApprovalEvent(shiftId: shift.shiftId), + ); + UiSnackbar.show( + context, + message: context.t.staff_shifts + .my_shift_card.timesheet_submitted, + type: UiSnackbarType.success, + ); + }, + ), + ); + }, ), const SizedBox(height: UiConstants.space32), ], diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart index c4c52421..67063ce3 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart @@ -7,9 +7,10 @@ import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_shifts/src/domain/utils/shift_date_utils.dart'; import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart'; -import 'package:staff_shifts/src/presentation/widgets/shift_card.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_card/index.dart'; /// Tab displaying the worker's assigned, pending, and cancelled shifts. class MyShiftsTab extends StatefulWidget { @@ -20,6 +21,8 @@ class MyShiftsTab extends StatefulWidget { required this.pendingAssignments, required this.cancelledShifts, this.initialDate, + this.submittedShiftIds = const {}, + this.submittingShiftId, }); /// Assigned shifts for the current week. @@ -34,6 +37,12 @@ class MyShiftsTab extends StatefulWidget { /// Initial date to select in the calendar. final DateTime? initialDate; + /// Set of shift IDs that have been successfully submitted for approval. + final Set submittedShiftIds; + + /// The shift ID currently being submitted (null when idle). + final String? submittingShiftId; + @override State createState() => _MyShiftsTabState(); } @@ -42,9 +51,6 @@ class _MyShiftsTabState extends State { DateTime _selectedDate = DateTime.now(); int _weekOffset = 0; - /// Tracks which completed-shift cards have been submitted locally. - final Set _submittedShiftIds = {}; - @override void initState() { super.initState(); @@ -90,20 +96,7 @@ class _MyShiftsTabState extends State { }); } - List _getCalendarDays() { - final DateTime now = DateTime.now(); - final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday; - final int daysSinceFriday = (reactDayIndex + 2) % 7; - final DateTime start = now - .subtract(Duration(days: daysSinceFriday)) - .add(Duration(days: _weekOffset * 7)); - final DateTime startDate = - DateTime(start.year, start.month, start.day); - return List.generate( - 7, - (int index) => startDate.add(Duration(days: index)), - ); - } + List _getCalendarDays() => getCalendarDaysForOffset(_weekOffset); void _loadShiftsForCurrentWeek() { final List calendarDays = _getCalendarDays(); @@ -402,7 +395,9 @@ class _MyShiftsTabState extends State { final bool isCompleted = shift.status == AssignmentStatus.completed; final bool isSubmitted = - _submittedShiftIds.contains(shift.shiftId); + widget.submittedShiftIds.contains(shift.shiftId); + final bool isSubmitting = + widget.submittingShiftId == shift.shiftId; return Padding( padding: const EdgeInsets.only( @@ -414,10 +409,13 @@ class _MyShiftsTabState extends State { .toShiftDetailsById(shift.shiftId), showApprovalAction: isCompleted, isSubmitted: isSubmitted, + isSubmitting: isSubmitting, onSubmitForApproval: () { - setState(() { - _submittedShiftIds.add(shift.shiftId); - }); + ReadContext(context).read().add( + SubmitForApprovalEvent( + shiftId: shift.shiftId, + ), + ); UiSnackbar.show( context, message: context.t.staff_shifts 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 e35cf7cb..98a51de7 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 @@ -14,6 +14,7 @@ import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase. import 'package:staff_shifts/src/domain/usecases/get_shift_details_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/accept_shift_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/decline_shift_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/submit_for_approval_usecase.dart'; import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_bloc.dart'; import 'package:staff_shifts/src/presentation/utils/shift_tab_type.dart'; @@ -45,6 +46,9 @@ class StaffShiftsModule extends Module { i.addLazySingleton(ApplyForShiftUseCase.new); i.addLazySingleton(GetShiftDetailUseCase.new); i.addLazySingleton(GetProfileCompletionUseCase.new); + i.addLazySingleton( + () => SubmitForApprovalUseCase(i.get()), + ); // BLoC i.add( @@ -57,6 +61,7 @@ class StaffShiftsModule extends Module { getProfileCompletion: i.get(), acceptShift: i.get(), declineShift: i.get(), + submitForApproval: i.get(), ), ); i.add( diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/data/repositories/staff_main_repository_impl.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/data/repositories/staff_main_repository_impl.dart index 01f76599..c2b78c96 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/data/repositories/staff_main_repository_impl.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/data/repositories/staff_main_repository_impl.dart @@ -1,7 +1,6 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; - -import '../../domain/repositories/staff_main_repository_interface.dart'; +import 'package:staff_main/src/domain/repositories/staff_main_repository_interface.dart'; /// V2 API implementation of [StaffMainRepositoryInterface]. /// diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart index 2e6d5d0f..b76d7f2b 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; @@ -9,7 +8,9 @@ import 'package:staff_main/src/presentation/blocs/staff_main_state.dart'; /// /// Tracks the active bottom-bar tab index, profile completion status, and /// bottom bar visibility based on the current route. -class StaffMainCubit extends Cubit implements Disposable { +class StaffMainCubit extends Cubit + with BlocErrorHandler + implements Disposable { /// Creates a [StaffMainCubit]. StaffMainCubit({ required GetProfileCompletionUseCase getProfileCompletionUsecase, @@ -67,20 +68,21 @@ class StaffMainCubit extends Cubit implements Disposable { if (_isLoadingCompletion || isClosed) return; _isLoadingCompletion = true; - try { - final bool isComplete = await _getProfileCompletionUsecase(); - if (!isClosed) { - emit(state.copyWith(isProfileComplete: isComplete)); - } - } catch (e) { - // If there's an error, allow access to all features - debugPrint('Error loading profile completion: $e'); - if (!isClosed) { - emit(state.copyWith(isProfileComplete: true)); - } - } finally { - _isLoadingCompletion = false; - } + await handleError( + emit: emit, + action: () async { + final bool isComplete = await _getProfileCompletionUsecase(); + if (!isClosed) { + emit(state.copyWith(isProfileComplete: isComplete)); + } + }, + onError: (String errorKey) { + // If there's an error, allow access to all features + _isLoadingCompletion = false; + return state.copyWith(isProfileComplete: true); + }, + ); + _isLoadingCompletion = false; } /// Navigates to the tab at [index]. diff --git a/docs/BACKEND/API_GUIDES/00-initial-api-contracts.md b/docs/BACKEND/API_GUIDES/00-initial-api-contracts.md deleted file mode 100644 index 0ef98e5e..00000000 --- a/docs/BACKEND/API_GUIDES/00-initial-api-contracts.md +++ /dev/null @@ -1,369 +0,0 @@ -# KROW Workforce API Contracts - -Legacy note: -Use `/Users/wiel/Development/krow-workforce/docs/BACKEND/API_GUIDES/V2/README.md` for the current v2 frontend/backend integration surface. -This document reflects the earlier Data Connect-oriented contract mapping and should not be the source of truth for new v2 client work. - -This document captures all API contracts used by the Staff and Client mobile applications. The application backend is powered by **Firebase Data Connect (GraphQL)**, so traditional REST endpoints do not exist natively. For clarity and ease of reading for all engineering team members, the tables below formulate these GraphQL Data Connect queries and mutations into their **Conceptual REST Endpoints** alongside the actual **Data Connect Operation Name**. - ---- - -## Staff Application - -### Authentication / Onboarding Pages -*(Pages: get_started_page.dart, intro_page.dart, phone_verification_page.dart, profile_setup_page.dart)* - -#### Setup / User Validation API -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `GET /users/{id}` | -| **Data Connect OP** | `getUserById` | -| **Purpose** | Retrieves the base user profile to determine authentication status and role access (e.g., if the user is STAFF). | -| **Operation** | Query | -| **Inputs** | `id: UUID!` (Firebase UID) | -| **Outputs** | `User { id, email, phone, role }` | -| **Notes** | Required after OTP verification to route users appropriately. | - -#### Create Default User API -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `POST /users` | -| **Data Connect OP** | `createUser` | -| **Purpose** | Inserts a base user record into the system during initial signup. | -| **Operation** | Mutation | -| **Inputs** | `id: UUID!`, `role: UserBaseRole` | -| **Outputs** | `id` of newly created User | -| **Notes** | Used explicitly during the "Sign Up" flow if the user doesn't physically exist in the database. | - -#### Get Staff Profile API -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `GET /staff/user/{userId}` | -| **Data Connect OP** | `getStaffByUserId` | -| **Purpose** | Finds the specific Staff record associated with the base user ID. | -| **Operation** | Query | -| **Inputs** | `userId: UUID!` | -| **Outputs** | `Staffs { id, userId, fullName, email, phone, photoUrl, status }` | -| **Notes** | Needed to verify if a complete staff profile exists before allowing navigation to the main app dashboard. | - -#### Update Staff Profile API -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `PUT /staff/{id}` | -| **Data Connect OP** | `updateStaff` | -| **Purpose** | Saves onboarding data across Personal Info, Experience, and Preferred Locations pages. | -| **Operation** | Mutation | -| **Inputs** | `id: UUID!`, `fullName`, `email`, `phone`, `address`, etc. | -| **Outputs** | `id` | -| **Notes** | Called incrementally during the profile setup wizard as the user fills out step-by-step information. | - -### Home Page & Benefits Overview -*(Pages: worker_home_page.dart, benefits_overview_page.dart)* - -#### Load Today/Tomorrow Shifts -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `GET /staff/{staffId}/applications` | -| **Data Connect OP** | `getApplicationsByStaffId` | -| **Purpose** | Retrieves applications (shifts) assigned to the current staff member within a specific date range. | -| **Operation** | Query | -| **Inputs** | `staffId: UUID!`, `dayStart: Timestamp`, `dayEnd: Timestamp` | -| **Outputs** | `Applications { shift, shiftRole, status, createdAt }` | -| **Notes** | The frontend filters the query response for `CONFIRMED` applications to successfully display "Today's" and "Tomorrow's" shifts. | - -#### List Recommended Shifts -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `GET /shifts/recommended` | -| **Data Connect OP** | `listShifts` | -| **Purpose** | Fetches open shifts that are available for the staff to apply to. | -| **Operation** | Query | -| **Inputs** | None directly mapped on load, but fetches available items logically. | -| **Outputs** | `Shifts { id, title, orderId, cost, location, startTime, endTime, status }` | -| **Notes** | Limits output to 10 on the frontend. Should ideally rely on an active backend `$status: OPEN` parameter. | - -#### Benefits Summary API -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `GET /staff/{staffId}/benefits` | -| **Data Connect OP** | `listBenefitsDataByStaffId` | -| **Purpose** | Retrieves accrued benefits (e.g., Sick time, Vacation) to display gracefully on the home screen. | -| **Operation** | Query | -| **Inputs** | `staffId: UUID!` | -| **Outputs** | `BenefitsDatas { vendorBenefitPlan { title, total }, current }` | -| **Notes** | Used by `benefits_overview_page.dart`. Derives available metrics via `usedHours = total - current`. | - -### Find Shifts / Shift Details Pages -*(Pages: shifts_page.dart, shift_details_page.dart)* - -#### List Available Shifts Filtered -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `GET /shifts` | -| **Data Connect OP** | `filterShifts` | -| **Purpose** | Used to fetch Open Shifts in specific regions when the worker searches in the "Find Shifts" tab. | -| **Operation** | Query | -| **Inputs** | `$status: ShiftStatus`, `$dateFrom: Timestamp`, `$dateTo: Timestamp` | -| **Outputs** | `Shifts { id, title, location, cost, durationDays, order { business, vendor } }` | -| **Notes** | Main driver for discovering available work. | - -#### Get Shift Details -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `GET /shifts/{id}` | -| **Data Connect OP** | `getShiftById` | -| **Purpose** | Gets deeper details for a single shift including exact uniform requirements and managers. | -| **Operation** | Query | -| **Inputs** | `id: UUID!` | -| **Outputs** | `Shift { id, title, hours, cost, locationAddress, workersNeeded ... }` | -| **Notes** | Invoked when users click into a full `shift_details_page.dart`. | - -#### Apply To Shift -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `POST /applications` | -| **Data Connect OP** | `createApplication` | -| **Purpose** | Worker submits an intent to take an open shift (creates an application record). | -| **Operation** | Mutation | -| **Inputs** | `shiftId: UUID!`, `staffId: UUID!`, `roleId: UUID!`, `status: ApplicationStatus!` (e.g. `PENDING` or `CONFIRMED`), `origin: ApplicationOrigin!` (e.g. `STAFF`); optional: `checkInTime`, `checkOutTime` | -| **Outputs** | `application_insert.id` (Application ID) | -| **Notes** | The app uses `status: CONFIRMED` and `origin: STAFF` when claiming; backend also supports `PENDING` for admin review flows. After creation, shift-role assigned count and shift filled count are updated. | - -### Availability Page -*(Pages: availability_page.dart)* - -#### Get Default Availability -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `GET /staff/{staffId}/availabilities` | -| **Data Connect OP** | `listStaffAvailabilitiesByStaffId` | -| **Purpose** | Fetches the standard Mon-Sun recurring availability for a staff member. | -| **Operation** | Query | -| **Inputs** | `staffId: UUID!` | -| **Outputs** | `StaffAvailabilities { dayOfWeek, isAvailable, startTime, endTime }` | -| **Notes** | Bound to Monday through Sunday configuration. | - -#### Update Availability -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `PUT /staff/availabilities/{id}` | -| **Data Connect OP** | `updateStaffAvailability` (or `createStaffAvailability` for new entries) | -| **Purpose** | Upserts availability preferences. | -| **Operation** | Mutation | -| **Inputs** | `staffId`, `dayOfWeek`, `isAvailable`, `startTime`, `endTime` | -| **Outputs** | `id` | -| **Notes** | Called individually per day edited. | - -### Payments Page -*(Pages: payments_page.dart, early_pay_page.dart)* - -#### Get Recent Payments -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `GET /staff/{staffId}/payments` | -| **Data Connect OP** | `listRecentPaymentsByStaffId` | -| **Purpose** | Loads the history of earnings and timesheets completed by the staff. | -| **Operation** | Query | -| **Inputs** | `staffId: UUID!` | -| **Outputs** | `Payments { amount, processDate, shiftId, status }` | -| **Notes** | Displays historical metrics under the comprehensive Earnings tab. | - -### Compliance / Profiles -*(Pages: certificates_page.dart, documents_page.dart, tax_forms_page.dart, form_i9_page.dart, form_w4_page.dart)* - -#### Get Tax Forms -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `GET /staff/{staffId}/tax-forms` | -| **Data Connect OP** | `getTaxFormsByStaffId` | -| **Purpose** | Check the filing status and detailed inputs of I9 and W4 forms. | -| **Operation** | Query | -| **Inputs** | `staffId: UUID!` | -| **Outputs** | `TaxForms { formType, isCompleted, updatedDate }` | -| **Notes** | Crucial requirement for staff to be eligible to apply for highly regulated shifts. | - -#### Update Tax Forms -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `PUT /tax-forms/{id}` | -| **Data Connect OP** | `updateTaxForm` | -| **Purpose** | Submits state and filing for the given tax form type (W4/I9). | -| **Operation** | Mutation | -| **Inputs** | `id`, `dataPoints...` | -| **Outputs** | `id` | -| **Notes** | Modifies the core compliance state variables directly. | - ---- - -## Client Application - -### Authentication / Intro -*(Pages: client_sign_in_page.dart, client_get_started_page.dart)* - -#### Client User Validation API -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `GET /users/{id}` | -| **Data Connect OP** | `getUserById` | -| **Purpose** | Retrieves the base user profile to determine authentication status and role access (ensuring user is BUSINESS). | -| **Operation** | Query | -| **Inputs** | `id: UUID!` (Firebase UID) | -| **Outputs** | `User { id, email, phone, userRole }` | -| **Notes** | Validates against conditional statements checking `userRole == BUSINESS` or `BOTH`. | - -#### Get Businesses By User API -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `GET /business/user/{userId}` | -| **Data Connect OP** | `getBusinessesByUserId` | -| **Purpose** | Maps the authenticated user to their client business context. | -| **Operation** | Query | -| **Inputs** | `userId: String!` | -| **Outputs** | `Businesses { id, businessName, email, contactName }` | -| **Notes** | Dictates the working scopes (Business ID) across the entire application lifecycle and binds the user. | - -### Hubs Page -*(Pages: client_hubs_page.dart, edit_hub_page.dart, hub_details_page.dart)* - -#### List Hubs by Team -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `GET /teams/{teamId}/hubs` | -| **Data Connect OP** | `getTeamHubsByTeamId` | -| **Purpose** | Fetches the primary working sites (Hubs) for a client context by using Team mapping. | -| **Operation** | Query | -| **Inputs** | `teamId: UUID!` | -| **Outputs** | `TeamHubs { id, hubName, address, managerName, isActive }` | -| **Notes** | `teamId` is derived first from `getTeamsByOwnerId(ownerId: businessId)`. | - -#### Create / Update / Delete Hub -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `POST /team-hubs` / `PUT /team-hubs/{id}` / `DELETE /team-hubs/{id}` | -| **Data Connect OP** | `createTeamHub` / `updateTeamHub` / `deleteTeamHub` | -| **Purpose** | Provisions, Edits details directly, or Removes a Team Hub location. | -| **Operation** | Mutation | -| **Inputs** | `id: UUID!`, optionally `hubName`, `address`, etc. | -| **Outputs** | `id` | -| **Notes** | Fired from `edit_hub_page.dart` mutations. | - -### Orders Page -*(Pages: create_order_page.dart, view_orders_page.dart, recurring_order_page.dart)* - -#### Create Order -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `POST /orders` | -| **Data Connect OP** | `createOrder` | -| **Purpose** | Submits a new request for temporary staff requirements. | -| **Operation** | Mutation | -| **Inputs** | `businessId`, `eventName`, `orderType`, `status` | -| **Outputs** | `id` (Order ID) | -| **Notes** | This explicitly invokes an order pipeline, meaning Shift instances are subsequently created through secondary mutations triggered after order instantiation. | - -#### List Orders -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `GET /business/{businessId}/orders` | -| **Data Connect OP** | `listOrdersByBusinessId` | -| **Purpose** | Retrieves all ongoing and past staff requests from the client. | -| **Operation** | Query | -| **Inputs** | `businessId: UUID!` | -| **Outputs** | `Orders { id, eventName }` | -| **Notes** | Populates the `view_orders_page.dart`. | - -### Billing Pages -*(Pages: billing_page.dart, pending_invoices_page.dart, completion_review_page.dart)* - -#### List Invoices -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `GET /business/{businessId}/invoices` | -| **Data Connect OP** | `listInvoicesByBusinessId` | -| **Purpose** | Fetches all invoices bound directly to the active business context (mapped directly in Firebase Schema). | -| **Operation** | Query | -| **Inputs** | `businessId: UUID!` | -| **Outputs** | `Invoices { id, amount, issueDate, status }` | -| **Notes** | Used massively across all Billing view tabs. | - -#### Mark / Dispute Invoice -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `PUT /invoices/{id}` | -| **Data Connect OP** | `updateInvoice` | -| **Purpose** | Actively marks an invoice as disputed or pays it directly (altering status). | -| **Operation** | Mutation | -| **Inputs** | `id: UUID!`, `status: InvoiceStatus` | -| **Outputs** | `id` | -| **Notes** | Disputing usually involves setting a `disputeReason` flag state dynamically via builder pattern. | - -### Reports Page -*(Pages: reports_page.dart, coverage_report_page.dart, performance_report_page.dart)* - -#### Get Coverage Stats -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `GET /business/{businessId}/coverage` | -| **Data Connect OP** | `listShiftsForCoverage` | -| **Purpose** | Provides data on Shifts grouped by Date for fulfillment calculations. | -| **Operation** | Query | -| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` | -| **Outputs** | `Shifts { id, date, workersNeeded, filled, status }` | -| **Notes** | The frontend aggregates the raw backend rows to compose Coverage percentage natively. | - -#### Get Daily Ops Stats -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `GET /business/{businessId}/dailyops` | -| **Data Connect OP** | `listShiftsForDailyOpsByBusiness` | -| **Purpose** | Supplies current day operations and shift tracking progress. | -| **Operation** | Query | -| **Inputs** | `businessId: UUID!`, `date: Timestamp!` | -| **Outputs** | `Shifts { id, title, location, workersNeeded, filled }` | -| **Notes** | - | - -#### Get Forecast Stats -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `GET /business/{businessId}/forecast` | -| **Data Connect OP** | `listShiftsForForecastByBusiness` | -| **Purpose** | Retrieves scheduled future shifts to calculate financial run-rates. | -| **Operation** | Query | -| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` | -| **Outputs** | `Shifts { id, date, workersNeeded, hours, cost }` | -| **Notes** | The App maps hours `x` cost to deliver Financial Dashboards. | - -#### Get Performance KPIs -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `GET /business/{businessId}/performance` | -| **Data Connect OP** | `listShiftsForPerformanceByBusiness` | -| **Purpose** | Fetches historical data allowing time-to-fill and completion-rate calculations. | -| **Operation** | Query | -| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` | -| **Outputs** | `Shifts { id, workersNeeded, filled, createdAt, filledAt }` | -| **Notes** | Data Connect exposes timestamps so the App calculates `avgFillTimeHours`. | - -#### Get No-Show Metrics -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `GET /business/{businessId}/noshows` | -| **Data Connect OP** | `listShiftsForNoShowRangeByBusiness` | -| **Purpose** | Retrieves shifts where workers historically ghosted the platform. | -| **Operation** | Query | -| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` | -| **Outputs** | `Shifts { id, date }` | -| **Notes** | Accompanies `listApplicationsForNoShowRange` cascading querying to generate full report. | - -#### Get Spend Analytics -| Field | Description | -|---|---| -| **Conceptual Endpoint** | `GET /business/{businessId}/spend` | -| **Data Connect OP** | `listInvoicesForSpendByBusiness` | -| **Purpose** | Detailed invoice aggregates for Spend metrics filtering. | -| **Operation** | Query | -| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` | -| **Outputs** | `Invoices { id, issueDate, dueDate, amount, status }` | -| **Notes** | Used explicitly under the "Spend Report" graphings. | - ----