Merge dev into feature branch

This commit is contained in:
2026-03-19 13:16:04 +05:30
273 changed files with 7867 additions and 3654 deletions

View File

@@ -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) - `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 - 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 ## Design System Tokens
- Colors: `UiColors.*` - Colors: `UiColors.*`
- Typography: `UiTypography.*` - Typography: `UiTypography.*`
- Spacing: `UiConstants.space*` (e.g., `space3`, `space4`, `space6`) - Spacing: `UiConstants.space*` (e.g., `space3`, `space4`, `space6`)
- App bar: `UiAppBar` - 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) ## Review Patterns (grep-based checks)
- `Color(0x` for hardcoded colors - `Color(0x` for hardcoded colors
- `TextStyle(` for custom text styles - `TextStyle(` for custom text styles

View File

@@ -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

View File

@@ -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<FirebaseAuthService>(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`.

View File

@@ -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

View File

@@ -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<String, AsyncValue<List<BenefitHistory>>>` 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 "<relevant keywords>"` 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 "<clear, concise title>" --body "<full markdown body>" --label "<label1>,<label2>"
```
**Title conventions:**
- Bugs: `[Bug] <concise description of the problem>`
- Features: `[Feature] <concise description of the feature>`
- Tech Debt: `[Tech Debt] <concise description>`
- Chore: `[Chore] <concise description>`
## 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:
<types>
<type>
<name>user</name>
<description>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.</description>
<when_to_save>When you learn any details about the user's role, preferences, responsibilities, or knowledge</when_to_save>
<how_to_use>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.</how_to_use>
<examples>
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]
</examples>
</type>
<type>
<name>feedback</name>
<description>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.</description>
<when_to_save>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.</when_to_save>
<how_to_use>Let these memories guide your behavior so that the user does not need to offer the same guidance twice.</how_to_use>
<body_structure>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.</body_structure>
<examples>
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]
</examples>
</type>
<type>
<name>project</name>
<description>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.</description>
<when_to_save>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.</when_to_save>
<how_to_use>Use these memories to more fully understand the details and nuance behind the user's request and make better informed suggestions.</how_to_use>
<body_structure>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.</body_structure>
<examples>
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]
</examples>
</type>
<type>
<name>reference</name>
<description>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.</description>
<when_to_save>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_to_save>
<how_to_use>When the user references an external system or information that may be in an external system.</how_to_use>
<examples>
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]
</examples>
</type>
</types>
## 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.

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:core_localization/core_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
@@ -84,7 +85,7 @@ class _SessionListenerState extends State<SessionListener> {
if (!_isInitialState) { if (!_isInitialState) {
debugPrint('[SessionListener] Session error: ${state.errorMessage}'); debugPrint('[SessionListener] Session error: ${state.errorMessage}');
_showSessionErrorDialog( _showSessionErrorDialog(
state.errorMessage ?? 'Session error occurred', state.errorMessage ?? t.session.error_title,
); );
} else { } else {
_isInitialState = false; _isInitialState = false;
@@ -101,22 +102,21 @@ class _SessionListenerState extends State<SessionListener> {
/// Shows a dialog when the session expires. /// Shows a dialog when the session expires.
void _showSessionExpiredDialog() { void _showSessionExpiredDialog() {
final Translations translations = t;
showDialog<void>( showDialog<void>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (BuildContext context) { builder: (BuildContext dialogContext) {
return AlertDialog( return AlertDialog(
title: const Text('Session Expired'), title: Text(translations.session.expired_title),
content: const Text( content: Text(translations.session.expired_message),
'Your session has expired. Please log in again to continue.',
),
actions: <Widget>[ actions: <Widget>[
TextButton( TextButton(
onPressed: () { onPressed: () {
Modular.to.popSafe(); Navigator.of(dialogContext).pop();
_proceedToLogin(); _proceedToLogin();
}, },
child: const Text('Log In'), child: Text(translations.session.log_in),
), ),
], ],
); );
@@ -126,27 +126,28 @@ class _SessionListenerState extends State<SessionListener> {
/// Shows a dialog when a session error occurs, with retry option. /// Shows a dialog when a session error occurs, with retry option.
void _showSessionErrorDialog(String errorMessage) { void _showSessionErrorDialog(String errorMessage) {
final Translations translations = t;
showDialog<void>( showDialog<void>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (BuildContext context) { builder: (BuildContext dialogContext) {
return AlertDialog( return AlertDialog(
title: const Text('Session Error'), title: Text(translations.session.error_title),
content: Text(errorMessage), content: Text(errorMessage),
actions: <Widget>[ actions: <Widget>[
TextButton( TextButton(
onPressed: () { onPressed: () {
// User can retry by dismissing and continuing // 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( TextButton(
onPressed: () { onPressed: () {
Modular.to.popSafe(); Navigator.of(dialogContext).pop();
_proceedToLogin(); _proceedToLogin();
}, },
child: const Text('Log Out'), child: Text(translations.session.log_out),
), ),
], ],
); );

View File

@@ -34,7 +34,8 @@ dependencies:
path: ../../packages/features/client/orders/create_order path: ../../packages/features/client/orders/create_order
krow_core: krow_core:
path: ../../packages/core path: ../../packages/core
krow_domain:
path: ../../packages/domain
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
flutter_modular: ^6.3.2 flutter_modular: ^6.3.2
flutter_bloc: ^8.1.3 flutter_bloc: ^8.1.3

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:core_localization/core_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
@@ -97,7 +98,7 @@ class _SessionListenerState extends State<SessionListener> {
if (!_isInitialState) { if (!_isInitialState) {
debugPrint('[SessionListener] Session error: ${state.errorMessage}'); debugPrint('[SessionListener] Session error: ${state.errorMessage}');
_showSessionErrorDialog( _showSessionErrorDialog(
state.errorMessage ?? 'Session error occurred', state.errorMessage ?? t.session.error_title,
); );
} else { } else {
_isInitialState = false; _isInitialState = false;
@@ -114,22 +115,21 @@ class _SessionListenerState extends State<SessionListener> {
/// Shows a dialog when the session expires. /// Shows a dialog when the session expires.
void _showSessionExpiredDialog() { void _showSessionExpiredDialog() {
final Translations translations = t;
showDialog<void>( showDialog<void>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (BuildContext context) { builder: (BuildContext dialogContext) {
return AlertDialog( return AlertDialog(
title: const Text('Session Expired'), title: Text(translations.session.expired_title),
content: const Text( content: Text(translations.session.expired_message),
'Your session has expired. Please log in again to continue.',
),
actions: <Widget>[ actions: <Widget>[
TextButton( TextButton(
onPressed: () { onPressed: () {
Modular.to.popSafe(); Navigator.of(dialogContext).pop();
_proceedToLogin(); _proceedToLogin();
}, },
child: const Text('Log In'), child: Text(translations.session.log_in),
), ),
], ],
); );
@@ -139,27 +139,28 @@ class _SessionListenerState extends State<SessionListener> {
/// Shows a dialog when a session error occurs, with retry option. /// Shows a dialog when a session error occurs, with retry option.
void _showSessionErrorDialog(String errorMessage) { void _showSessionErrorDialog(String errorMessage) {
final Translations translations = t;
showDialog<void>( showDialog<void>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (BuildContext context) { builder: (BuildContext dialogContext) {
return AlertDialog( return AlertDialog(
title: const Text('Session Error'), title: Text(translations.session.error_title),
content: Text(errorMessage), content: Text(errorMessage),
actions: <Widget>[ actions: <Widget>[
TextButton( TextButton(
onPressed: () { onPressed: () {
// User can retry by dismissing and continuing // 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( TextButton(
onPressed: () { onPressed: () {
Modular.to.popSafe(); Navigator.of(dialogContext).pop();
_proceedToLogin(); _proceedToLogin();
}, },
child: const Text('Log Out'), child: Text(translations.session.log_out),
), ),
], ],
); );

View File

@@ -28,6 +28,8 @@ dependencies:
path: ../../packages/features/staff/staff_main path: ../../packages/features/staff/staff_main
krow_core: krow_core:
path: ../../packages/core path: ../../packages/core
krow_domain:
path: ../../packages/domain
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
flutter_modular: ^6.3.0 flutter_modular: ^6.3.0
firebase_core: ^4.4.0 firebase_core: ^4.4.0

View File

@@ -42,6 +42,10 @@ export 'src/services/session/client_session_store.dart';
export 'src/services/session/staff_session_store.dart'; export 'src/services/session/staff_session_store.dart';
export 'src/services/session/v2_session_service.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 // Device Services
export 'src/services/device/camera/camera_service.dart'; export 'src/services/device/camera/camera_service.dart';
export 'src/services/device/gallery/gallery_service.dart'; export 'src/services/device/gallery/gallery_service.dart';

View File

@@ -3,6 +3,10 @@ import 'package:flutter_modular/flutter_modular.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:krow_domain/krow_domain.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'; import '../core.dart';
/// A module that provides core services and shared dependencies. /// 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<AuthTokenProvider>(FirebaseAuthTokenProvider.new);
// 7. Firebase Auth Service (so features never import firebase_auth)
i.addLazySingleton<FirebaseAuthService>(FirebaseAuthServiceImpl.new);
// 8. Register Geofence Device Services
i.addLazySingleton<LocationService>(() => const LocationService()); i.addLazySingleton<LocationService>(() => const LocationService());
i.addLazySingleton<NotificationService>(() => NotificationService()); i.addLazySingleton<NotificationService>(() => NotificationService());
i.addLazySingleton<StorageService>(() => StorageService()); i.addLazySingleton<StorageService>(() => StorageService());

View File

@@ -60,6 +60,20 @@ extension StaffNavigator on IModularNavigator {
safePush(StaffPaths.benefits); 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: <String, dynamic>{
'benefitId': benefitId,
'benefitTitle': benefitTitle,
},
);
}
void toStaffMain() { void toStaffMain() {
safePushNamedAndRemoveUntil('${StaffPaths.main}/home/', (_) => false); safePushNamedAndRemoveUntil('${StaffPaths.main}/home/', (_) => false);
} }

View File

@@ -75,6 +75,9 @@ class StaffPaths {
/// Benefits overview page. /// Benefits overview page.
static const String benefits = '/worker-main/home/benefits'; 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. /// Shifts tab - view and manage shifts.
/// ///
/// Browse available shifts, accepted shifts, and shift history. /// Browse available shifts, accepted shifts, and shift history.

View File

@@ -48,6 +48,26 @@ abstract final class ClientEndpoints {
static const ApiEndpoint coverageCoreTeam = static const ApiEndpoint coverageCoreTeam =
ApiEndpoint('/client/coverage/core-team'); 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. /// Hubs list.
static const ApiEndpoint hubs = ApiEndpoint('/client/hubs'); static const ApiEndpoint hubs = ApiEndpoint('/client/hubs');
@@ -162,4 +182,28 @@ abstract final class ClientEndpoints {
/// Cancel late worker assignment. /// Cancel late worker assignment.
static ApiEndpoint coverageCancelLateWorker(String assignmentId) => static ApiEndpoint coverageCancelLateWorker(String assignmentId) =>
ApiEndpoint('/client/coverage/late-workers/$assignmentId/cancel'); 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');
} }

View File

@@ -2,39 +2,42 @@ import 'package:krow_domain/krow_domain.dart' show ApiEndpoint;
/// Core infrastructure endpoints (upload, signed URLs, LLM, verifications, /// Core infrastructure endpoints (upload, signed URLs, LLM, verifications,
/// rapid orders). /// rapid orders).
///
/// Paths are at the unified API root level (not under `/core/`).
abstract final class CoreEndpoints { abstract final class CoreEndpoints {
/// Upload a file. /// Upload a file.
static const ApiEndpoint uploadFile = static const ApiEndpoint uploadFile = ApiEndpoint('/upload-file');
ApiEndpoint('/core/upload-file');
/// Create a signed URL for a file. /// Create a signed URL for a file.
static const ApiEndpoint createSignedUrl = static const ApiEndpoint createSignedUrl = ApiEndpoint('/create-signed-url');
ApiEndpoint('/core/create-signed-url');
/// Invoke a Large Language Model. /// Invoke a Large Language Model.
static const ApiEndpoint invokeLlm = ApiEndpoint('/core/invoke-llm'); static const ApiEndpoint invokeLlm = ApiEndpoint('/invoke-llm');
/// Root for verification operations. /// Root for verification operations.
static const ApiEndpoint verifications = static const ApiEndpoint verifications = ApiEndpoint('/verifications');
ApiEndpoint('/core/verifications');
/// Get status of a verification job. /// Get status of a verification job.
static ApiEndpoint verificationStatus(String id) => static ApiEndpoint verificationStatus(String id) =>
ApiEndpoint('/core/verifications/$id'); ApiEndpoint('/verifications/$id');
/// Review a verification decision. /// Review a verification decision.
static ApiEndpoint verificationReview(String id) => static ApiEndpoint verificationReview(String id) =>
ApiEndpoint('/core/verifications/$id/review'); ApiEndpoint('/verifications/$id/review');
/// Retry a verification job. /// Retry a verification job.
static ApiEndpoint verificationRetry(String id) => static ApiEndpoint verificationRetry(String id) =>
ApiEndpoint('/core/verifications/$id/retry'); ApiEndpoint('/verifications/$id/retry');
/// Transcribe audio to text for rapid orders. /// Transcribe audio to text for rapid orders.
static const ApiEndpoint transcribeRapidOrder = static const ApiEndpoint transcribeRapidOrder =
ApiEndpoint('/core/rapid-orders/transcribe'); ApiEndpoint('/rapid-orders/transcribe');
/// Parse text to structured rapid order. /// Parse text to structured rapid order.
static const ApiEndpoint parseRapidOrder = 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');
} }

View File

@@ -105,6 +105,10 @@ abstract final class StaffEndpoints {
/// Benefits. /// Benefits.
static const ApiEndpoint benefits = ApiEndpoint('/staff/profile/benefits'); static const ApiEndpoint benefits = ApiEndpoint('/staff/profile/benefits');
/// Benefits history.
static const ApiEndpoint benefitsHistory =
ApiEndpoint('/staff/profile/benefits/history');
/// Time card. /// Time card.
static const ApiEndpoint timeCard = static const ApiEndpoint timeCard =
ApiEndpoint('/staff/profile/time-card'); ApiEndpoint('/staff/profile/time-card');
@@ -112,6 +116,10 @@ abstract final class StaffEndpoints {
/// Privacy settings. /// Privacy settings.
static const ApiEndpoint privacy = ApiEndpoint('/staff/profile/privacy'); static const ApiEndpoint privacy = ApiEndpoint('/staff/profile/privacy');
/// Preferred locations.
static const ApiEndpoint locations =
ApiEndpoint('/staff/profile/locations');
/// FAQs. /// FAQs.
static const ApiEndpoint faqs = ApiEndpoint('/staff/faqs'); static const ApiEndpoint faqs = ApiEndpoint('/staff/faqs');
@@ -177,4 +185,16 @@ abstract final class StaffEndpoints {
/// Delete certificate by ID. /// Delete certificate by ID.
static ApiEndpoint certificateDelete(String certificateId) => static ApiEndpoint certificateDelete(String certificateId) =>
ApiEndpoint('/staff/profile/certificates/$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');
} }

View File

@@ -97,7 +97,7 @@ mixin ApiErrorHandler {
); );
case DioExceptionType.cancel: case DioExceptionType.cancel:
return UnknownException( return const UnknownException(
technicalMessage: 'Request cancelled', technicalMessage: 'Request cancelled',
); );

View File

@@ -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<String?> getIdToken({bool forceRefresh});
}

View File

@@ -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<User?> 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<String?> 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<PhoneSignInResult> 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<String> signInWithEmailAndPassword({
required String email,
required String password,
});
/// Signs out the current user from Firebase Auth locally.
Future<void> signOut();
/// Returns the current user's Firebase ID token.
///
/// Returns `null` if no user is signed in.
Future<String?> 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<String?>? _pendingVerification;
@override
Stream<User?> 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<String?> verifyPhoneNumber({
required String phoneNumber,
void Function()? onAutoVerified,
}) async {
final Completer<String?> completer = Completer<String?>();
_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<String?>? completer = _pendingVerification;
if (completer != null && !completer.isCompleted) {
completer.completeError(Exception('Phone verification cancelled.'));
}
_pendingVerification = null;
}
@override
Future<PhoneSignInResult> 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<String> 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<void> signOut() async {
await _auth.signOut();
}
@override
Future<String?> getIdToken() async {
final firebase.User? user = _auth.currentUser;
return user?.getIdToken();
}
}

View File

@@ -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<String?> getIdToken({bool forceRefresh = false}) async {
final User? user = FirebaseAuth.instance.currentUser;
return user?.getIdToken(forceRefresh);
}
}

View File

@@ -1,5 +1,45 @@
import 'package:intl/intl.dart'; 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 /// Formats a time string (ISO 8601 or HH:mm) into 12-hour format
/// (e.g. "9:00 AM"). /// (e.g. "9:00 AM").
/// ///

View File

@@ -12,6 +12,13 @@
"english": "English", "english": "English",
"spanish": "Español" "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": { "settings": {
"language": "Language", "language": "Language",
"change_language": "Change Language" "change_language": "Change Language"
@@ -665,7 +672,14 @@
"status": { "status": {
"pending": "Pending", "pending": "Pending",
"submitted": "Submitted" "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": { "auto_match": {
@@ -964,11 +978,15 @@
"retry": "Retry", "retry": "Retry",
"clock_in_anyway": "Clock In Anyway", "clock_in_anyway": "Clock In Anyway",
"override_title": "Justification Required", "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_hint": "Enter your justification...",
"override_submit": "Clock In", "override_submit": "Submit",
"overridden_title": "Location Not Verified", "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": { "availability": {
@@ -1159,6 +1177,8 @@
"upload": { "upload": {
"instructions": "Please select a valid PDF file to upload.", "instructions": "Please select a valid PDF file to upload.",
"pdf_banner": "Only PDF files are accepted. Maximum file size is 10MB.", "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.", "file_not_found": "File not found.",
"submit": "Submit Document", "submit": "Submit Document",
"select_pdf": "Select PDF File", "select_pdf": "Select PDF File",
@@ -1337,14 +1357,22 @@
"applying_dialog": { "applying_dialog": {
"title": "Applying" "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": { "my_shift_card": {
"submit_for_approval": "Submit for Approval", "submit_for_approval": "Submit for Approval",
"timesheet_submitted": "Timesheet submitted for client approval", "timesheet_submitted": "Timesheet submitted for client approval",
"checked_in": "Checked in", "checked_in": "Checked in",
"submitted": "SUBMITTED", "submitted": "SUBMITTED",
"ready_to_submit": "READY TO SUBMIT" "ready_to_submit": "READY TO SUBMIT",
"submitting": "SUBMITTING..."
}, },
"shift_location": { "shift_location": {
"could_not_open_maps": "Could not open maps" "could_not_open_maps": "Could not open maps"
@@ -1457,11 +1485,14 @@
"shift": { "shift": {
"no_open_roles": "There are no open positions available for this shift.", "no_open_roles": "There are no open positions available for this shift.",
"application_not_found": "Your application couldn't be found.", "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": { "clock_in": {
"location_verification_required": "Please wait for location verification before clocking 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": { "generic": {
"unknown": "Something went wrong. Please try again.", "unknown": "Something went wrong. Please try again.",
@@ -1762,7 +1793,9 @@
"workers": "Workers", "workers": "Workers",
"error_occurred": "An error occurred", "error_occurred": "An error occurred",
"retry": "Retry", "retry": "Retry",
"shifts": "Shifts" "shifts": "Shifts",
"overall_coverage": "Overall Coverage",
"live_activity": "LIVE ACTIVITY"
}, },
"calendar": { "calendar": {
"prev_week": "\u2190 Prev Week", "prev_week": "\u2190 Prev Week",
@@ -1771,7 +1804,9 @@
}, },
"stats": { "stats": {
"checked_in": "Checked In", "checked_in": "Checked In",
"en_route": "En Route" "en_route": "En Route",
"on_site": "On Site",
"late": "Late"
}, },
"alert": { "alert": {
"workers_running_late(count)": { "workers_running_late(count)": {
@@ -1779,6 +1814,45 @@
"other": "$count workers are running late" "other": "$count workers are running late"
}, },
"auto_backup_searching": "Auto-backup system is searching for replacements." "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": { "client_reports_common": {

View File

@@ -12,6 +12,13 @@
"english": "English", "english": "English",
"spanish": "Español" "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": { "settings": {
"language": "Idioma", "language": "Idioma",
"change_language": "Cambiar Idioma" "change_language": "Cambiar Idioma"
@@ -660,7 +667,14 @@
"status": { "status": {
"pending": "Pendiente", "pending": "Pendiente",
"submitted": "Enviado" "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": { "auto_match": {
@@ -959,11 +973,15 @@
"retry": "Reintentar", "retry": "Reintentar",
"clock_in_anyway": "Registrar Entrada", "clock_in_anyway": "Registrar Entrada",
"override_title": "Justificación Requerida", "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_hint": "Ingrese su justificación...",
"override_submit": "Registrar Entrada", "override_submit": "Enviar",
"overridden_title": "Ubicación No Verificada", "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": { "availability": {
@@ -1154,6 +1172,8 @@
"upload": { "upload": {
"instructions": "Por favor selecciona un archivo PDF válido para subir.", "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": "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", "submit": "Enviar Documento",
"select_pdf": "Seleccionar Archivo PDF", "select_pdf": "Seleccionar Archivo PDF",
"attestation": "Certifico que este documento es genuino y válido.", "attestation": "Certifico que este documento es genuino y válido.",
@@ -1332,14 +1352,22 @@
"applying_dialog": { "applying_dialog": {
"title": "Solicitando" "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": { "my_shift_card": {
"submit_for_approval": "Enviar para Aprobación", "submit_for_approval": "Enviar para Aprobación",
"timesheet_submitted": "Hoja de tiempo enviada para aprobación del cliente", "timesheet_submitted": "Hoja de tiempo enviada para aprobación del cliente",
"checked_in": "Registrado", "checked_in": "Registrado",
"submitted": "ENVIADO", "submitted": "ENVIADO",
"ready_to_submit": "LISTO PARA ENVIAR" "ready_to_submit": "LISTO PARA ENVIAR",
"submitting": "ENVIANDO..."
}, },
"shift_location": { "shift_location": {
"could_not_open_maps": "No se pudo abrir mapas" "could_not_open_maps": "No se pudo abrir mapas"
@@ -1452,11 +1480,14 @@
"shift": { "shift": {
"no_open_roles": "No hay posiciones abiertas disponibles para este turno.", "no_open_roles": "No hay posiciones abiertas disponibles para este turno.",
"application_not_found": "No se pudo encontrar tu solicitud.", "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": { "clock_in": {
"location_verification_required": "Por favor, espera la verificaci\u00f3n de ubicaci\u00f3n antes de registrar entrada.", "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": { "generic": {
"unknown": "Algo sali\u00f3 mal. Por favor, intenta de nuevo.", "unknown": "Algo sali\u00f3 mal. Por favor, intenta de nuevo.",
@@ -1762,7 +1793,9 @@
"workers": "Trabajadores", "workers": "Trabajadores",
"error_occurred": "Ocurri\u00f3 un error", "error_occurred": "Ocurri\u00f3 un error",
"retry": "Reintentar", "retry": "Reintentar",
"shifts": "Turnos" "shifts": "Turnos",
"overall_coverage": "Cobertura General",
"live_activity": "ACTIVIDAD EN VIVO"
}, },
"calendar": { "calendar": {
"prev_week": "\u2190 Semana Anterior", "prev_week": "\u2190 Semana Anterior",
@@ -1771,7 +1804,9 @@
}, },
"stats": { "stats": {
"checked_in": "Registrado", "checked_in": "Registrado",
"en_route": "En Camino" "en_route": "En Camino",
"on_site": "En Sitio",
"late": "Tarde"
}, },
"alert": { "alert": {
"workers_running_late(count)": { "workers_running_late(count)": {
@@ -1779,6 +1814,45 @@
"other": "$count trabajadores est\u00e1n llegando tarde" "other": "$count trabajadores est\u00e1n llegando tarde"
}, },
"auto_backup_searching": "El sistema de respaldo autom\u00e1tico est\u00e1 buscando reemplazos." "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": { "client_reports_common": {

View File

@@ -124,6 +124,8 @@ String _translateShiftError(String errorType) {
return t.errors.shift.application_not_found; return t.errors.shift.application_not_found;
case 'no_active_shift': case 'no_active_shift':
return t.errors.shift.no_active_shift; return t.errors.shift.no_active_shift;
case 'not_found':
return t.errors.shift.not_found;
default: default:
return t.errors.generic.unknown; return t.errors.generic.unknown;
} }

View File

@@ -82,6 +82,7 @@ class UiChip extends StatelessWidget {
final Row content = Row( final Row content = Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[ children: <Widget>[
if (leadingIcon != null) ...<Widget>[ if (leadingIcon != null) ...<Widget>[
Icon(leadingIcon, size: iconSize, color: contentColor), Icon(leadingIcon, size: iconSize, color: contentColor),

View File

@@ -84,6 +84,7 @@ class UiNoticeBanner extends StatelessWidget {
style: UiTypography.body2b.copyWith( style: UiTypography.body2b.copyWith(
color: titleColor ?? UiColors.primary, color: titleColor ?? UiColors.primary,
), ),
overflow: TextOverflow.ellipsis,
), ),
], ],
], ],

View File

@@ -18,6 +18,7 @@ export 'src/entities/enums/invoice_status.dart';
export 'src/entities/enums/onboarding_status.dart'; export 'src/entities/enums/onboarding_status.dart';
export 'src/entities/enums/order_type.dart'; export 'src/entities/enums/order_type.dart';
export 'src/entities/enums/payment_status.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/shift_status.dart';
export 'src/entities/enums/staff_industry.dart'; export 'src/entities/enums/staff_industry.dart';
export 'src/entities/enums/staff_skill.dart'; export 'src/entities/enums/staff_skill.dart';
@@ -72,6 +73,7 @@ export 'src/entities/orders/recent_order.dart';
// Financial & Payroll // Financial & Payroll
export 'src/entities/benefits/benefit.dart'; export 'src/entities/benefits/benefit.dart';
export 'src/entities/benefits/benefit_history.dart';
export 'src/entities/financial/invoice.dart'; export 'src/entities/financial/invoice.dart';
export 'src/entities/financial/billing_account.dart'; export 'src/entities/financial/billing_account.dart';
export 'src/entities/financial/current_bill.dart'; export 'src/entities/financial/current_bill.dart';

View File

@@ -2,6 +2,14 @@ import 'package:equatable/equatable.dart';
/// Represents a geographic location obtained from the device. /// Represents a geographic location obtained from the device.
class DeviceLocation extends Equatable { 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. /// Latitude in degrees.
final double latitude; final double latitude;
@@ -14,14 +22,6 @@ class DeviceLocation extends Equatable {
/// Time when this location was determined. /// Time when this location was determined.
final DateTime timestamp; final DateTime timestamp;
/// Creates a [DeviceLocation] instance.
const DeviceLocation({
required this.latitude,
required this.longitude,
required this.accuracy,
required this.timestamp,
});
@override @override
List<Object?> get props => [latitude, longitude, accuracy, timestamp]; List<Object?> get props => <Object?>[latitude, longitude, accuracy, timestamp];
} }

View File

@@ -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<String, dynamic> 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<String, dynamic> toJson() {
return <String, dynamic>{
'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<Object?> get props => <Object?>[
historyId,
benefitId,
benefitType,
title,
status,
effectiveAt,
endedAt,
trackedHours,
targetHours,
notes,
];
}

View File

@@ -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;
}

View File

@@ -18,6 +18,10 @@ class AssignedShift extends Equatable {
required this.startTime, required this.startTime,
required this.endTime, required this.endTime,
required this.hourlyRateCents, required this.hourlyRateCents,
required this.hourlyRate,
required this.totalRateCents,
required this.totalRate,
required this.clientName,
required this.orderType, required this.orderType,
required this.status, required this.status,
}); });
@@ -33,6 +37,10 @@ class AssignedShift extends Equatable {
startTime: DateTime.parse(json['startTime'] as String), startTime: DateTime.parse(json['startTime'] as String),
endTime: DateTime.parse(json['endTime'] as String), endTime: DateTime.parse(json['endTime'] as String),
hourlyRateCents: json['hourlyRateCents'] 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,
clientName: json['clientName'] as String? ?? '',
orderType: OrderType.fromJson(json['orderType'] as String?), orderType: OrderType.fromJson(json['orderType'] as String?),
status: AssignmentStatus.fromJson(json['status'] as String?), status: AssignmentStatus.fromJson(json['status'] as String?),
); );
@@ -62,6 +70,18 @@ class AssignedShift extends Equatable {
/// Pay rate in cents per hour. /// Pay rate in cents per hour.
final int hourlyRateCents; 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. /// Order type.
final OrderType orderType; final OrderType orderType;
@@ -79,6 +99,10 @@ class AssignedShift extends Equatable {
'startTime': startTime.toIso8601String(), 'startTime': startTime.toIso8601String(),
'endTime': endTime.toIso8601String(), 'endTime': endTime.toIso8601String(),
'hourlyRateCents': hourlyRateCents, 'hourlyRateCents': hourlyRateCents,
'hourlyRate': hourlyRate,
'totalRateCents': totalRateCents,
'totalRate': totalRate,
'clientName': clientName,
'orderType': orderType.toJson(), 'orderType': orderType.toJson(),
'status': status.toJson(), 'status': status.toJson(),
}; };
@@ -94,6 +118,10 @@ class AssignedShift extends Equatable {
startTime, startTime,
endTime, endTime,
hourlyRateCents, hourlyRateCents,
hourlyRate,
totalRateCents,
totalRate,
clientName,
orderType, orderType,
status, status,
]; ];

View File

@@ -12,10 +12,18 @@ class CompletedShift extends Equatable {
required this.shiftId, required this.shiftId,
required this.title, required this.title,
required this.location, required this.location,
required this.clientName,
required this.date, required this.date,
required this.startTime,
required this.endTime,
required this.minutesWorked, required this.minutesWorked,
required this.hourlyRateCents,
required this.hourlyRate,
required this.totalRateCents,
required this.totalRate,
required this.paymentStatus, required this.paymentStatus,
required this.status, required this.status,
this.timesheetStatus,
}); });
/// Deserialises from the V2 API JSON response. /// Deserialises from the V2 API JSON response.
@@ -25,10 +33,22 @@ class CompletedShift extends Equatable {
shiftId: json['shiftId'] as String, shiftId: json['shiftId'] as String,
title: json['title'] as String? ?? '', title: json['title'] as String? ?? '',
location: json['location'] as String? ?? '', location: json['location'] as String? ?? '',
clientName: json['clientName'] as String? ?? '',
date: DateTime.parse(json['date'] 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, 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?), paymentStatus: PaymentStatus.fromJson(json['paymentStatus'] as String?),
status: AssignmentStatus.completed, status: AssignmentStatus.completed,
timesheetStatus: json['timesheetStatus'] as String?,
); );
} }
@@ -44,18 +64,42 @@ class CompletedShift extends Equatable {
/// Human-readable location label. /// Human-readable location label.
final String location; final String location;
/// Name of the client / business for this shift.
final String clientName;
/// The date the shift was worked. /// The date the shift was worked.
final DateTime date; final DateTime date;
/// Scheduled start time.
final DateTime startTime;
/// Scheduled end time.
final DateTime endTime;
/// Total minutes worked (regular + overtime). /// Total minutes worked (regular + overtime).
final int minutesWorked; 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. /// Payment processing status.
final PaymentStatus paymentStatus; final PaymentStatus paymentStatus;
/// Assignment status (should always be `completed` for this class). /// Assignment status (should always be `completed` for this class).
final AssignmentStatus status; final AssignmentStatus status;
/// Timesheet status (e.g. `SUBMITTED`, `APPROVED`, `PAID`, or null).
final String? timesheetStatus;
/// Serialises to JSON. /// Serialises to JSON.
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return <String, dynamic>{ return <String, dynamic>{
@@ -63,9 +107,17 @@ class CompletedShift extends Equatable {
'shiftId': shiftId, 'shiftId': shiftId,
'title': title, 'title': title,
'location': location, 'location': location,
'clientName': clientName,
'date': date.toIso8601String(), 'date': date.toIso8601String(),
'startTime': startTime.toIso8601String(),
'endTime': endTime.toIso8601String(),
'minutesWorked': minutesWorked, 'minutesWorked': minutesWorked,
'hourlyRateCents': hourlyRateCents,
'hourlyRate': hourlyRate,
'totalRateCents': totalRateCents,
'totalRate': totalRate,
'paymentStatus': paymentStatus.toJson(), 'paymentStatus': paymentStatus.toJson(),
'timesheetStatus': timesheetStatus,
}; };
} }
@@ -75,8 +127,17 @@ class CompletedShift extends Equatable {
shiftId, shiftId,
title, title,
location, location,
clientName,
date, date,
startTime,
endTime,
minutesWorked, minutesWorked,
hourlyRateCents,
hourlyRate,
totalRateCents,
totalRate,
paymentStatus, paymentStatus,
timesheetStatus,
status,
]; ];
} }

View File

@@ -12,11 +12,13 @@ class OpenShift extends Equatable {
required this.shiftId, required this.shiftId,
required this.roleId, required this.roleId,
required this.roleName, required this.roleName,
this.clientName = '',
required this.location, required this.location,
required this.date, required this.date,
required this.startTime, required this.startTime,
required this.endTime, required this.endTime,
required this.hourlyRateCents, required this.hourlyRateCents,
required this.hourlyRate,
required this.orderType, required this.orderType,
required this.instantBook, required this.instantBook,
required this.requiredWorkerCount, required this.requiredWorkerCount,
@@ -28,11 +30,13 @@ class OpenShift extends Equatable {
shiftId: json['shiftId'] as String, shiftId: json['shiftId'] as String,
roleId: json['roleId'] as String, roleId: json['roleId'] as String,
roleName: json['roleName'] as String, roleName: json['roleName'] as String,
clientName: json['clientName'] as String? ?? '',
location: json['location'] as String? ?? '', location: json['location'] as String? ?? '',
date: DateTime.parse(json['date'] as String), date: DateTime.parse(json['date'] as String),
startTime: DateTime.parse(json['startTime'] as String), startTime: DateTime.parse(json['startTime'] as String),
endTime: DateTime.parse(json['endTime'] as String), endTime: DateTime.parse(json['endTime'] as String),
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
orderType: OrderType.fromJson(json['orderType'] as String?), orderType: OrderType.fromJson(json['orderType'] as String?),
instantBook: json['instantBook'] as bool? ?? false, instantBook: json['instantBook'] as bool? ?? false,
requiredWorkerCount: json['requiredWorkerCount'] as int? ?? 1, requiredWorkerCount: json['requiredWorkerCount'] as int? ?? 1,
@@ -48,6 +52,9 @@ class OpenShift extends Equatable {
/// Display name of the role. /// Display name of the role.
final String roleName; final String roleName;
/// Name of the client/business offering this shift.
final String clientName;
/// Human-readable location label. /// Human-readable location label.
final String location; final String location;
@@ -63,6 +70,9 @@ class OpenShift extends Equatable {
/// Pay rate in cents per hour. /// Pay rate in cents per hour.
final int hourlyRateCents; final int hourlyRateCents;
/// Pay rate in dollars per hour.
final double hourlyRate;
/// Order type. /// Order type.
final OrderType orderType; final OrderType orderType;
@@ -78,11 +88,13 @@ class OpenShift extends Equatable {
'shiftId': shiftId, 'shiftId': shiftId,
'roleId': roleId, 'roleId': roleId,
'roleName': roleName, 'roleName': roleName,
'clientName': clientName,
'location': location, 'location': location,
'date': date.toIso8601String(), 'date': date.toIso8601String(),
'startTime': startTime.toIso8601String(), 'startTime': startTime.toIso8601String(),
'endTime': endTime.toIso8601String(), 'endTime': endTime.toIso8601String(),
'hourlyRateCents': hourlyRateCents, 'hourlyRateCents': hourlyRateCents,
'hourlyRate': hourlyRate,
'orderType': orderType.toJson(), 'orderType': orderType.toJson(),
'instantBook': instantBook, 'instantBook': instantBook,
'requiredWorkerCount': requiredWorkerCount, 'requiredWorkerCount': requiredWorkerCount,
@@ -94,11 +106,13 @@ class OpenShift extends Equatable {
shiftId, shiftId,
roleId, roleId,
roleName, roleName,
clientName,
location, location,
date, date,
startTime, startTime,
endTime, endTime,
hourlyRateCents, hourlyRateCents,
hourlyRate,
orderType, orderType,
instantBook, instantBook,
requiredWorkerCount, requiredWorkerCount,

View File

@@ -11,7 +11,7 @@ class Shift extends Equatable {
/// Creates a [Shift]. /// Creates a [Shift].
const Shift({ const Shift({
required this.id, required this.id,
required this.orderId, this.orderId,
required this.title, required this.title,
required this.status, required this.status,
required this.startsAt, required this.startsAt,
@@ -25,19 +25,39 @@ class Shift extends Equatable {
required this.requiredWorkers, required this.requiredWorkers,
required this.assignedWorkers, required this.assignedWorkers,
this.notes, this.notes,
this.clockInMode,
this.allowClockInOverride,
this.nfcTagId,
this.clientName,
this.roleName,
}); });
/// Deserialises from the V2 API JSON response. /// 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<String, dynamic> json) { factory Shift.fromJson(Map<String, dynamic> json) {
final String? clientName = json['clientName'] as String?;
final String? roleName = json['roleName'] as String?;
return Shift( return Shift(
id: json['id'] as String, id: json['id'] as String? ?? json['shiftId'] as String,
orderId: json['orderId'] as String, orderId: json['orderId'] as String?,
title: json['title'] as String? ?? '', title: json['title'] as String? ??
roleName ??
clientName ??
'',
status: ShiftStatus.fromJson(json['status'] as String?), status: ShiftStatus.fromJson(json['status'] as String?),
startsAt: DateTime.parse(json['startsAt'] as String), startsAt: DateTime.parse(
endsAt: DateTime.parse(json['endsAt'] as String), 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', 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?, locationAddress: json['locationAddress'] as String?,
latitude: parseDouble(json['latitude']), latitude: parseDouble(json['latitude']),
longitude: parseDouble(json['longitude']), longitude: parseDouble(json['longitude']),
@@ -45,14 +65,19 @@ class Shift extends Equatable {
requiredWorkers: json['requiredWorkers'] as int? ?? 1, requiredWorkers: json['requiredWorkers'] as int? ?? 1,
assignedWorkers: json['assignedWorkers'] as int? ?? 0, assignedWorkers: json['assignedWorkers'] as int? ?? 0,
notes: json['notes'] as String?, 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. /// The shift row id.
final String id; final String id;
/// The parent order id. /// The parent order id (may be null for today-shifts endpoint).
final String orderId; final String? orderId;
/// Display title. /// Display title.
final String title; final String title;
@@ -93,6 +118,21 @@ class Shift extends Equatable {
/// Free-form notes for the shift. /// Free-form notes for the shift.
final String? notes; 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. /// Serialises to JSON.
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return <String, dynamic>{ return <String, dynamic>{
@@ -111,6 +151,11 @@ class Shift extends Equatable {
'requiredWorkers': requiredWorkers, 'requiredWorkers': requiredWorkers,
'assignedWorkers': assignedWorkers, 'assignedWorkers': assignedWorkers,
'notes': notes, 'notes': notes,
'clockInMode': clockInMode,
'allowClockInOverride': allowClockInOverride,
'nfcTagId': nfcTagId,
'clientName': clientName,
'roleName': roleName,
}; };
} }
@@ -140,5 +185,10 @@ class Shift extends Equatable {
requiredWorkers, requiredWorkers,
assignedWorkers, assignedWorkers,
notes, notes,
clockInMode,
allowClockInOverride,
nfcTagId,
clientName,
roleName,
]; ];
} }

View File

@@ -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/application_status.dart';
import 'package:krow_domain/src/entities/enums/assignment_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/enums/order_type.dart';
import 'package:krow_domain/src/entities/shifts/shift.dart';
/// Full detail view of a shift for the staff member. /// Full detail view of a shift for the staff member.
/// ///
@@ -18,17 +19,27 @@ class ShiftDetail extends Equatable {
this.description, this.description,
required this.location, required this.location,
this.address, this.address,
required this.clientName,
this.latitude,
this.longitude,
required this.date, required this.date,
required this.startTime, required this.startTime,
required this.endTime, required this.endTime,
required this.roleId, required this.roleId,
required this.roleName, required this.roleName,
required this.hourlyRateCents, required this.hourlyRateCents,
required this.hourlyRate,
required this.totalRateCents,
required this.totalRate,
required this.orderType, required this.orderType,
required this.requiredCount, required this.requiredCount,
required this.confirmedCount, required this.confirmedCount,
this.assignmentStatus, this.assignmentStatus,
this.applicationStatus, this.applicationStatus,
this.clockInMode,
required this.allowClockInOverride,
this.geofenceRadiusMeters,
this.nfcTagId,
}); });
/// Deserialises from the V2 API JSON response. /// Deserialises from the V2 API JSON response.
@@ -39,12 +50,18 @@ class ShiftDetail extends Equatable {
description: json['description'] as String?, description: json['description'] as String?,
location: json['location'] as String? ?? '', location: json['location'] as String? ?? '',
address: json['address'] 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), date: DateTime.parse(json['date'] as String),
startTime: DateTime.parse(json['startTime'] as String), startTime: DateTime.parse(json['startTime'] as String),
endTime: DateTime.parse(json['endTime'] as String), endTime: DateTime.parse(json['endTime'] as String),
roleId: json['roleId'] as String, roleId: json['roleId'] as String,
roleName: json['roleName'] as String, roleName: json['roleName'] as String,
hourlyRateCents: json['hourlyRateCents'] 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,
orderType: OrderType.fromJson(json['orderType'] as String?), orderType: OrderType.fromJson(json['orderType'] as String?),
requiredCount: json['requiredCount'] as int? ?? 1, requiredCount: json['requiredCount'] as int? ?? 1,
confirmedCount: json['confirmedCount'] as int? ?? 0, confirmedCount: json['confirmedCount'] as int? ?? 0,
@@ -54,6 +71,10 @@ class ShiftDetail extends Equatable {
applicationStatus: json['applicationStatus'] != null applicationStatus: json['applicationStatus'] != null
? ApplicationStatus.fromJson(json['applicationStatus'] as String?) ? ApplicationStatus.fromJson(json['applicationStatus'] as String?)
: null, : 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. /// Street address of the shift location.
final String? address; 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). /// Date of the shift (same as startTime, kept for display grouping).
final DateTime date; final DateTime date;
@@ -90,6 +120,15 @@ class ShiftDetail extends Equatable {
/// Pay rate in cents per hour. /// Pay rate in cents per hour.
final int hourlyRateCents; 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. /// Order type.
final OrderType orderType; final OrderType orderType;
@@ -105,6 +144,26 @@ class ShiftDetail extends Equatable {
/// Current worker's application status, if applied. /// Current worker's application status, if applied.
final ApplicationStatus? applicationStatus; 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. /// Serialises to JSON.
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return <String, dynamic>{ return <String, dynamic>{
@@ -113,17 +172,27 @@ class ShiftDetail extends Equatable {
'description': description, 'description': description,
'location': location, 'location': location,
'address': address, 'address': address,
'clientName': clientName,
'latitude': latitude,
'longitude': longitude,
'date': date.toIso8601String(), 'date': date.toIso8601String(),
'startTime': startTime.toIso8601String(), 'startTime': startTime.toIso8601String(),
'endTime': endTime.toIso8601String(), 'endTime': endTime.toIso8601String(),
'roleId': roleId, 'roleId': roleId,
'roleName': roleName, 'roleName': roleName,
'hourlyRateCents': hourlyRateCents, 'hourlyRateCents': hourlyRateCents,
'hourlyRate': hourlyRate,
'totalRateCents': totalRateCents,
'totalRate': totalRate,
'orderType': orderType.toJson(), 'orderType': orderType.toJson(),
'requiredCount': requiredCount, 'requiredCount': requiredCount,
'confirmedCount': confirmedCount, 'confirmedCount': confirmedCount,
'assignmentStatus': assignmentStatus?.toJson(), 'assignmentStatus': assignmentStatus?.toJson(),
'applicationStatus': applicationStatus?.toJson(), 'applicationStatus': applicationStatus?.toJson(),
'clockInMode': clockInMode,
'allowClockInOverride': allowClockInOverride,
'geofenceRadiusMeters': geofenceRadiusMeters,
'nfcTagId': nfcTagId,
}; };
} }
@@ -134,16 +203,26 @@ class ShiftDetail extends Equatable {
description, description,
location, location,
address, address,
clientName,
latitude,
longitude,
date, date,
startTime, startTime,
endTime, endTime,
roleId, roleId,
roleName, roleName,
hourlyRateCents, hourlyRateCents,
hourlyRate,
totalRateCents,
totalRate,
orderType, orderType,
requiredCount, requiredCount,
confirmedCount, confirmedCount,
assignmentStatus, assignmentStatus,
applicationStatus, applicationStatus,
clockInMode,
allowClockInOverride,
geofenceRadiusMeters,
nfcTagId,
]; ];
} }

View File

@@ -17,6 +17,12 @@ class TodayShift extends Equatable {
required this.startTime, required this.startTime,
required this.endTime, required this.endTime,
required this.attendanceStatus, required this.attendanceStatus,
this.clientName = '',
this.hourlyRateCents = 0,
this.hourlyRate = 0.0,
this.totalRateCents = 0,
this.totalRate = 0.0,
this.locationAddress,
this.clockInAt, this.clockInAt,
}); });
@@ -30,6 +36,12 @@ class TodayShift extends Equatable {
startTime: DateTime.parse(json['startTime'] as String), startTime: DateTime.parse(json['startTime'] as String),
endTime: DateTime.parse(json['endTime'] as String), endTime: DateTime.parse(json['endTime'] as String),
attendanceStatus: AttendanceStatusType.fromJson(json['attendanceStatus'] 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 clockInAt: json['clockInAt'] != null
? DateTime.parse(json['clockInAt'] as String) ? DateTime.parse(json['clockInAt'] as String)
: null, : null,
@@ -48,6 +60,24 @@ class TodayShift extends Equatable {
/// Human-readable location label (clock-point or shift location). /// Human-readable location label (clock-point or shift location).
final String 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. /// Scheduled start time.
final DateTime startTime; final DateTime startTime;
@@ -67,6 +97,12 @@ class TodayShift extends Equatable {
'shiftId': shiftId, 'shiftId': shiftId,
'roleName': roleName, 'roleName': roleName,
'location': location, 'location': location,
'clientName': clientName,
'hourlyRateCents': hourlyRateCents,
'hourlyRate': hourlyRate,
'totalRateCents': totalRateCents,
'totalRate': totalRate,
'locationAddress': locationAddress,
'startTime': startTime.toIso8601String(), 'startTime': startTime.toIso8601String(),
'endTime': endTime.toIso8601String(), 'endTime': endTime.toIso8601String(),
'attendanceStatus': attendanceStatus.toJson(), 'attendanceStatus': attendanceStatus.toJson(),
@@ -80,6 +116,12 @@ class TodayShift extends Equatable {
shiftId, shiftId,
roleName, roleName,
location, location,
clientName,
hourlyRateCents,
hourlyRate,
totalRateCents,
totalRate,
locationAddress,
startTime, startTime,
endTime, endTime,
attendanceStatus, attendanceStatus,

View File

@@ -33,7 +33,10 @@ class ClientAuthenticationModule extends Module {
void binds(Injector i) { void binds(Injector i) {
// Repositories // Repositories
i.addLazySingleton<AuthRepositoryInterface>( i.addLazySingleton<AuthRepositoryInterface>(
() => AuthRepositoryImpl(apiService: i.get<BaseApiService>()), () => AuthRepositoryImpl(
apiService: i.get<BaseApiService>(),
firebaseAuthService: i.get<FirebaseAuthService>(),
),
); );
// UseCases // UseCases

View File

@@ -1,7 +1,6 @@
import 'dart:developer' as developer; import 'dart:developer' as developer;
import 'package:client_authentication/src/domain/repositories/auth_repository_interface.dart'; 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_core/core.dart';
import 'package:krow_domain/krow_domain.dart' import 'package:krow_domain/krow_domain.dart'
show show
@@ -10,7 +9,6 @@ import 'package:krow_domain/krow_domain.dart'
AppException, AppException,
BaseApiService, BaseApiService,
ClientSession, ClientSession,
InvalidCredentialsException,
NetworkException, NetworkException,
PasswordMismatchException, PasswordMismatchException,
SignInFailedException, SignInFailedException,
@@ -21,20 +19,23 @@ import 'package:krow_domain/krow_domain.dart'
/// Production implementation of the [AuthRepositoryInterface] for the client app. /// Production implementation of the [AuthRepositoryInterface] for the client app.
/// ///
/// Uses Firebase Auth client-side for sign-in (to maintain local auth state for /// Uses [FirebaseAuthService] from core for local Firebase sign-in (to maintain
/// the [AuthInterceptor]), then calls V2 `GET /auth/session` to retrieve /// local auth state for the [AuthInterceptor]), then calls V2 `GET /auth/session`
/// business context. Sign-up provisioning (tenant, business, memberships) is /// to retrieve business context. Sign-up provisioning (tenant, business,
/// handled entirely server-side by the V2 API. /// memberships) is handled entirely server-side by the V2 API.
class AuthRepositoryImpl implements AuthRepositoryInterface { class AuthRepositoryImpl implements AuthRepositoryInterface {
/// Creates an [AuthRepositoryImpl] with the given [BaseApiService]. /// Creates an [AuthRepositoryImpl] with the given dependencies.
AuthRepositoryImpl({required BaseApiService apiService}) AuthRepositoryImpl({
: _apiService = apiService; required BaseApiService apiService,
required FirebaseAuthService firebaseAuthService,
}) : _apiService = apiService,
_firebaseAuthService = firebaseAuthService;
/// The V2 API service for backend calls. /// The V2 API service for backend calls.
final BaseApiService _apiService; final BaseApiService _apiService;
/// Firebase Auth instance for client-side sign-in/sign-up. /// Core Firebase Auth service abstraction.
firebase.FirebaseAuth get _auth => firebase.FirebaseAuth.instance; final FirebaseAuthService _firebaseAuthService;
@override @override
Future<User> signInWithEmail({ Future<User> signInWithEmail({
@@ -42,38 +43,26 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
required String password, required String password,
}) async { }) async {
try { 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. // via Identity Toolkit and returns a full auth envelope.
final ApiResponse response = await _apiService.post( final ApiResponse response = await _apiService.post(
AuthEndpoints.clientSignIn, AuthEndpoints.clientSignIn,
data: <String, dynamic>{ data: <String, dynamic>{'email': email, 'password': password},
'email': email,
'password': password,
},
); );
final Map<String, dynamic> body = final Map<String, dynamic> body = response.data as Map<String, dynamic>;
response.data as Map<String, dynamic>;
// Step 2: Sign in locally so AuthInterceptor can attach Bearer tokens // Step 2: Sign in locally so AuthInterceptor can attach Bearer tokens
// to subsequent requests. The V2 API already validated credentials, so // to subsequent requests. The V2 API already validated credentials, so
// email/password sign-in establishes the local Firebase Auth state. // email/password sign-in establishes the local Firebase Auth state.
final firebase.UserCredential credential = await _firebaseAuthService.signInWithEmailAndPassword(
await _auth.signInWithEmailAndPassword(
email: email, email: email,
password: password, 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 // Step 3: Populate session store from the V2 auth envelope directly
// (no need for a separate GET /auth/session call). // (no need for a separate GET /auth/session call).
return _populateStoreFromAuthEnvelope(body, firebaseUser, email); return _populateStoreFromAuthEnvelope(body, email);
} on AppException { } on AppException {
rethrow; rethrow;
} catch (e) { } catch (e) {
@@ -106,38 +95,34 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
// Step 2: Sign in locally to Firebase Auth so AuthInterceptor works // Step 2: Sign in locally to Firebase Auth so AuthInterceptor works
// for subsequent requests. The V2 API already created the Firebase // for subsequent requests. The V2 API already created the Firebase
// account, so this should succeed. // account, so this should succeed.
final firebase.UserCredential credential = try {
await _auth.signInWithEmailAndPassword( await _firebaseAuthService.signInWithEmailAndPassword(
email: email, email: email,
password: password, password: password,
); );
} on SignInFailedException {
final firebase.User? firebaseUser = credential.user;
if (firebaseUser == null) {
throw const SignUpFailedException( throw const SignUpFailedException(
technicalMessage: 'Local Firebase sign-in failed after V2 sign-up', technicalMessage: 'Local Firebase sign-in failed after V2 sign-up',
); );
} }
// Step 3: Populate store from the sign-up response envelope. // Step 3: Populate store from the sign-up response envelope.
return _populateStoreFromAuthEnvelope(body, firebaseUser, email); return _populateStoreFromAuthEnvelope(body, 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}',
);
}
} on AppException { } on AppException {
rethrow; rethrow;
} catch (e) { } 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'); 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. // Step 1: Call V2 sign-out endpoint for server-side token revocation.
await _apiService.post(AuthEndpoints.clientSignOut); await _apiService.post(AuthEndpoints.clientSignOut);
} catch (e) { } catch (e) {
developer.log( developer.log('V2 sign-out request failed: $e', name: 'AuthRepository');
'V2 sign-out request failed: $e',
name: 'AuthRepository',
);
// Continue with local sign-out even if server-side fails. // Continue with local sign-out even if server-side fails.
} }
try { try {
// Step 2: Sign out from local Firebase Auth. // Step 2: Sign out from local Firebase Auth via core service.
await _auth.signOut(); await _firebaseAuthService.signOut();
} catch (e) { } catch (e) {
throw Exception('Error signing out locally: $e'); throw Exception('Error signing out locally: $e');
} }
@@ -181,7 +163,6 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
/// returns a domain [User]. /// returns a domain [User].
User _populateStoreFromAuthEnvelope( User _populateStoreFromAuthEnvelope(
Map<String, dynamic> envelope, Map<String, dynamic> envelope,
firebase.User firebaseUser,
String fallbackEmail, String fallbackEmail,
) { ) {
final Map<String, dynamic>? userJson = final Map<String, dynamic>? userJson =
@@ -202,14 +183,15 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
'userId': userJson['id'] ?? userJson['userId'], 'userId': userJson['id'] ?? userJson['userId'],
}, },
}; };
final ClientSession clientSession = final ClientSession clientSession = ClientSession.fromJson(
ClientSession.fromJson(normalisedEnvelope); normalisedEnvelope,
);
ClientSessionStore.instance.setSession(clientSession); ClientSessionStore.instance.setSession(clientSession);
} }
final String userId = final String userId = userJson?['id'] as String? ??
userJson?['id'] as String? ?? firebaseUser.uid; (_firebaseAuthService.currentUserUid ?? '');
final String? email = userJson?['email'] as String? ?? fallbackEmail; final String email = userJson?['email'] as String? ?? fallbackEmail;
return User( return User(
id: userId, id: userId,

View File

@@ -14,7 +14,6 @@ dependencies:
flutter_bloc: ^8.1.0 flutter_bloc: ^8.1.0
flutter_modular: ^6.3.0 flutter_modular: ^6.3.0
equatable: ^2.0.5 equatable: ^2.0.5
firebase_auth: ^6.1.2
# Architecture Packages # Architecture Packages
design_system: design_system:

View File

@@ -3,7 +3,7 @@ import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import 'package:billing/src/data/repositories_impl/billing_repository_impl.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/approve_invoice.dart';
import 'package:billing/src/domain/usecases/dispute_invoice.dart'; import 'package:billing/src/domain/usecases/dispute_invoice.dart';
import 'package:billing/src/domain/usecases/get_bank_accounts.dart'; import 'package:billing/src/domain/usecases/get_bank_accounts.dart';
@@ -29,8 +29,8 @@ class BillingModule extends Module {
@override @override
void binds(Injector i) { void binds(Injector i) {
// Repositories // Repositories
i.addLazySingleton<BillingRepository>( i.addLazySingleton<BillingRepositoryInterface>(
() => BillingRepositoryImpl(apiService: i.get<BaseApiService>()), () => BillingRepositoryInterfaceImpl(apiService: i.get<BaseApiService>()),
); );
// Use Cases // Use Cases

View File

@@ -1,14 +1,14 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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]. /// All backend calls go through [BaseApiService] with [ClientEndpoints].
class BillingRepositoryImpl implements BillingRepository { class BillingRepositoryInterfaceImpl implements BillingRepositoryInterface {
/// Creates a [BillingRepositoryImpl]. /// Creates a [BillingRepositoryInterfaceImpl].
BillingRepositoryImpl({required BaseApiService apiService}) BillingRepositoryInterfaceImpl({required BaseApiService apiService})
: _apiService = apiService; : _apiService = apiService;
/// The API service used for all HTTP requests. /// The API service used for all HTTP requests.

View File

@@ -5,7 +5,7 @@ import 'package:krow_domain/krow_domain.dart';
/// This interface defines the contract for accessing billing-related data, /// This interface defines the contract for accessing billing-related data,
/// acting as a boundary between the Domain and Data layers. /// acting as a boundary between the Domain and Data layers.
/// It allows the Domain layer to remain independent of specific data sources. /// 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. /// Fetches bank accounts associated with the business.
Future<List<BillingAccount>> getBankAccounts(); Future<List<BillingAccount>> getBankAccounts();

View File

@@ -1,6 +1,6 @@
import 'package:krow_core/core.dart'; 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. /// Use case for approving an invoice.
class ApproveInvoiceUseCase extends UseCase<String, void> { class ApproveInvoiceUseCase extends UseCase<String, void> {
@@ -8,7 +8,7 @@ class ApproveInvoiceUseCase extends UseCase<String, void> {
ApproveInvoiceUseCase(this._repository); ApproveInvoiceUseCase(this._repository);
/// The billing repository. /// The billing repository.
final BillingRepository _repository; final BillingRepositoryInterface _repository;
@override @override
Future<void> call(String input) => _repository.approveInvoice(input); Future<void> call(String input) => _repository.approveInvoice(input);

View File

@@ -1,6 +1,6 @@
import 'package:krow_core/core.dart'; 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]. /// Params for [DisputeInvoiceUseCase].
class DisputeInvoiceParams { class DisputeInvoiceParams {
@@ -20,7 +20,7 @@ class DisputeInvoiceUseCase extends UseCase<DisputeInvoiceParams, void> {
DisputeInvoiceUseCase(this._repository); DisputeInvoiceUseCase(this._repository);
/// The billing repository. /// The billing repository.
final BillingRepository _repository; final BillingRepositoryInterface _repository;
@override @override
Future<void> call(DisputeInvoiceParams input) => Future<void> call(DisputeInvoiceParams input) =>

View File

@@ -1,7 +1,7 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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. /// Use case for fetching the bank accounts associated with the business.
class GetBankAccountsUseCase extends NoInputUseCase<List<BillingAccount>> { class GetBankAccountsUseCase extends NoInputUseCase<List<BillingAccount>> {
@@ -9,7 +9,7 @@ class GetBankAccountsUseCase extends NoInputUseCase<List<BillingAccount>> {
GetBankAccountsUseCase(this._repository); GetBankAccountsUseCase(this._repository);
/// The billing repository. /// The billing repository.
final BillingRepository _repository; final BillingRepositoryInterface _repository;
@override @override
Future<List<BillingAccount>> call() => _repository.getBankAccounts(); Future<List<BillingAccount>> call() => _repository.getBankAccounts();

View File

@@ -1,16 +1,16 @@
import 'package:krow_core/core.dart'; 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. /// 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<int> { class GetCurrentBillAmountUseCase extends NoInputUseCase<int> {
/// Creates a [GetCurrentBillAmountUseCase]. /// Creates a [GetCurrentBillAmountUseCase].
GetCurrentBillAmountUseCase(this._repository); GetCurrentBillAmountUseCase(this._repository);
/// The billing repository. /// The billing repository.
final BillingRepository _repository; final BillingRepositoryInterface _repository;
@override @override
Future<int> call() => _repository.getCurrentBillCents(); Future<int> call() => _repository.getCurrentBillCents();

View File

@@ -1,7 +1,7 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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. /// Use case for fetching the invoice history.
/// ///
@@ -11,7 +11,7 @@ class GetInvoiceHistoryUseCase extends NoInputUseCase<List<Invoice>> {
GetInvoiceHistoryUseCase(this._repository); GetInvoiceHistoryUseCase(this._repository);
/// The billing repository. /// The billing repository.
final BillingRepository _repository; final BillingRepositoryInterface _repository;
@override @override
Future<List<Invoice>> call() => _repository.getInvoiceHistory(); Future<List<Invoice>> call() => _repository.getInvoiceHistory();

View File

@@ -1,7 +1,7 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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. /// Use case for fetching the pending invoices.
/// ///
@@ -11,7 +11,7 @@ class GetPendingInvoicesUseCase extends NoInputUseCase<List<Invoice>> {
GetPendingInvoicesUseCase(this._repository); GetPendingInvoicesUseCase(this._repository);
/// The billing repository. /// The billing repository.
final BillingRepository _repository; final BillingRepositoryInterface _repository;
@override @override
Future<List<Invoice>> call() => _repository.getPendingInvoices(); Future<List<Invoice>> call() => _repository.getPendingInvoices();

View File

@@ -1,16 +1,16 @@
import 'package:krow_core/core.dart'; 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. /// 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<int> { class GetSavingsAmountUseCase extends NoInputUseCase<int> {
/// Creates a [GetSavingsAmountUseCase]. /// Creates a [GetSavingsAmountUseCase].
GetSavingsAmountUseCase(this._repository); GetSavingsAmountUseCase(this._repository);
/// The billing repository. /// The billing repository.
final BillingRepository _repository; final BillingRepositoryInterface _repository;
@override @override
Future<int> call() => _repository.getSavingsCents(); Future<int> call() => _repository.getSavingsCents();

View File

@@ -1,7 +1,7 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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]. /// Parameters for [GetSpendBreakdownUseCase].
class SpendBreakdownParams { class SpendBreakdownParams {
@@ -20,14 +20,14 @@ class SpendBreakdownParams {
/// Use case for fetching the spending breakdown by category. /// Use case for fetching the spending breakdown by category.
/// ///
/// Delegates data retrieval to the [BillingRepository]. /// Delegates data retrieval to the [BillingRepositoryInterface].
class GetSpendBreakdownUseCase class GetSpendBreakdownUseCase
extends UseCase<SpendBreakdownParams, List<SpendItem>> { extends UseCase<SpendBreakdownParams, List<SpendItem>> {
/// Creates a [GetSpendBreakdownUseCase]. /// Creates a [GetSpendBreakdownUseCase].
GetSpendBreakdownUseCase(this._repository); GetSpendBreakdownUseCase(this._repository);
/// The billing repository. /// The billing repository.
final BillingRepository _repository; final BillingRepositoryInterface _repository;
@override @override
Future<List<SpendItem>> call(SpendBreakdownParams input) => Future<List<SpendItem>> call(SpendBreakdownParams input) =>

View File

@@ -1,5 +1,3 @@
import 'dart:developer' as developer;
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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'; import 'package:billing/src/presentation/blocs/billing_state.dart';
/// BLoC for managing billing state and data loading. /// 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<BillingEvent, BillingState> class BillingBloc extends Bloc<BillingEvent, BillingState>
with BlocErrorHandler<BillingState> { with BlocErrorHandler<BillingState> {
/// Creates a [BillingBloc] with the given use cases. /// Creates a [BillingBloc] with the given use cases.
@@ -35,52 +36,79 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
on<BillingPeriodChanged>(_onPeriodChanged); on<BillingPeriodChanged>(_onPeriodChanged);
} }
/// Use case for fetching bank accounts.
final GetBankAccountsUseCase _getBankAccounts; final GetBankAccountsUseCase _getBankAccounts;
/// Use case for fetching the current bill amount.
final GetCurrentBillAmountUseCase _getCurrentBillAmount; final GetCurrentBillAmountUseCase _getCurrentBillAmount;
/// Use case for fetching the savings amount.
final GetSavingsAmountUseCase _getSavingsAmount; final GetSavingsAmountUseCase _getSavingsAmount;
/// Use case for fetching pending invoices.
final GetPendingInvoicesUseCase _getPendingInvoices; final GetPendingInvoicesUseCase _getPendingInvoices;
/// Use case for fetching invoice history.
final GetInvoiceHistoryUseCase _getInvoiceHistory; final GetInvoiceHistoryUseCase _getInvoiceHistory;
/// Use case for fetching spending breakdown.
final GetSpendBreakdownUseCase _getSpendBreakdown; final GetSpendBreakdownUseCase _getSpendBreakdown;
/// Executes [loader] and returns null on failure, logging the error. /// Loads all billing data concurrently.
Future<T?> _loadSafe<T>(Future<T> Function() loader) async { ///
try { /// Uses [handleError] to surface errors to the UI via state
return await loader(); /// instead of silently swallowing them. Individual data fetches
} catch (e, stackTrace) { /// use [handleErrorWithResult] so partial failures populate
developer.log( /// with defaults rather than failing the entire load.
'Partial billing load failed: $e',
name: 'BillingBloc',
error: e,
stackTrace: stackTrace,
);
return null;
}
}
Future<void> _onLoadStarted( Future<void> _onLoadStarted(
BillingLoadStarted event, BillingLoadStarted event,
Emitter<BillingState> emit, Emitter<BillingState> emit,
) async { ) async {
await handleError(
emit: emit.call,
action: () async {
emit(state.copyWith(status: BillingStatus.loading)); emit(state.copyWith(status: BillingStatus.loading));
final SpendBreakdownParams spendParams = _dateRangeFor(state.periodTab); final SpendBreakdownParams spendParams =
_dateRangeFor(state.periodTab);
final List<Object?> results = await Future.wait<Object?>( final List<Object?> results = await Future.wait<Object?>(
<Future<Object?>>[ <Future<Object?>>[
_loadSafe<int>(() => _getCurrentBillAmount.call()), handleErrorWithResult<int>(
_loadSafe<int>(() => _getSavingsAmount.call()), action: () => _getCurrentBillAmount.call(),
_loadSafe<List<Invoice>>(() => _getPendingInvoices.call()), onError: (_) {},
_loadSafe<List<Invoice>>(() => _getInvoiceHistory.call()), ),
_loadSafe<List<SpendItem>>(() => _getSpendBreakdown.call(spendParams)), handleErrorWithResult<int>(
_loadSafe<List<BillingAccount>>(() => _getBankAccounts.call()), action: () => _getSavingsAmount.call(),
onError: (_) {},
),
handleErrorWithResult<List<Invoice>>(
action: () => _getPendingInvoices.call(),
onError: (_) {},
),
handleErrorWithResult<List<Invoice>>(
action: () => _getInvoiceHistory.call(),
onError: (_) {},
),
handleErrorWithResult<List<SpendItem>>(
action: () => _getSpendBreakdown.call(spendParams),
onError: (_) {},
),
handleErrorWithResult<List<BillingAccount>>(
action: () => _getBankAccounts.call(),
onError: (_) {},
),
], ],
); );
final int? currentBillCents = results[0] as int?; final int? currentBillCents = results[0] as int?;
final int? savingsCents = results[1] as int?; final int? savingsCents = results[1] as int?;
final List<Invoice>? pendingInvoices = results[2] as List<Invoice>?; final List<Invoice>? pendingInvoices =
final List<Invoice>? invoiceHistory = results[3] as List<Invoice>?; results[2] as List<Invoice>?;
final List<SpendItem>? spendBreakdown = results[4] as List<SpendItem>?; final List<Invoice>? invoiceHistory =
results[3] as List<Invoice>?;
final List<SpendItem>? spendBreakdown =
results[4] as List<SpendItem>?;
final List<BillingAccount>? bankAccounts = final List<BillingAccount>? bankAccounts =
results[5] as List<BillingAccount>?; results[5] as List<BillingAccount>?;
@@ -95,6 +123,12 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
bankAccounts: bankAccounts ?? state.bankAccounts, bankAccounts: bankAccounts ?? state.bankAccounts,
), ),
); );
},
onError: (String errorKey) => state.copyWith(
status: BillingStatus.failure,
errorMessage: errorKey,
),
);
} }
Future<void> _onPeriodChanged( Future<void> _onPeriodChanged(

View File

@@ -56,7 +56,7 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
final DateFormat formatter = DateFormat('EEEE, MMMM d'); final DateFormat formatter = DateFormat('EEEE, MMMM d');
final String dateLabel = resolvedInvoice.dueDate != null final String dateLabel = resolvedInvoice.dueDate != null
? formatter.format(resolvedInvoice.dueDate!) ? formatter.format(resolvedInvoice.dueDate!)
: 'N/A'; : 'N/A'; // TODO: localize
return Scaffold( return Scaffold(
appBar: UiAppBar( appBar: UiAppBar(
@@ -85,7 +85,7 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
bottomNavigationBar: Container( bottomNavigationBar: Container(
padding: const EdgeInsets.all(UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space5),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: UiColors.primaryForeground,
border: Border( border: Border(
top: BorderSide(color: UiColors.border.withValues(alpha: 0.5)), top: BorderSide(color: UiColors.border.withValues(alpha: 0.5)),
), ),

View File

@@ -19,7 +19,7 @@ class BillingPageSkeleton extends StatelessWidget {
padding: const EdgeInsets.all(UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space5),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: <Widget>[
// Pending invoices section header // Pending invoices section header
const UiShimmerSectionHeader(), const UiShimmerSectionHeader(),
const SizedBox(height: UiConstants.space3), const SizedBox(height: UiConstants.space3),
@@ -39,7 +39,7 @@ class BillingPageSkeleton extends StatelessWidget {
), ),
child: const Column( child: const Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: <Widget>[
UiShimmerLine(width: 160, height: 16), UiShimmerLine(width: 160, height: 16),
SizedBox(height: UiConstants.space4), SizedBox(height: UiConstants.space4),
// Breakdown rows // Breakdown rows

View File

@@ -10,7 +10,7 @@ class BreakdownRowSkeleton extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return const Row( return const Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: <Widget>[
UiShimmerLine(width: 100, height: 14), UiShimmerLine(width: 100, height: 14),
UiShimmerLine(width: 60, height: 14), UiShimmerLine(width: 60, height: 14),
], ],

View File

@@ -16,10 +16,10 @@ class InvoiceCardSkeleton extends StatelessWidget {
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: <Widget>[
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: <Widget>[
UiShimmerBox( UiShimmerBox(
width: 72, width: 72,
height: 24, height: 24,
@@ -35,10 +35,10 @@ class InvoiceCardSkeleton extends StatelessWidget {
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: <Widget>[
const Column( const Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: <Widget>[
UiShimmerLine(width: 80, height: 10), UiShimmerLine(width: 80, height: 10),
SizedBox(height: UiConstants.space1), SizedBox(height: UiConstants.space1),
UiShimmerLine(width: 100, height: 18), UiShimmerLine(width: 100, height: 18),

View File

@@ -95,8 +95,8 @@ class CompletionReviewActions extends StatelessWidget {
context: context, context: context,
builder: (BuildContext dialogContext) => AlertDialog( builder: (BuildContext dialogContext) => AlertDialog(
title: Text(t.client_billing.flag_dialog.title), title: Text(t.client_billing.flag_dialog.title),
surfaceTintColor: Colors.white, surfaceTintColor: UiColors.primaryForeground,
backgroundColor: Colors.white, backgroundColor: UiColors.primaryForeground,
content: TextField( content: TextField(
controller: controller, controller: controller,
decoration: InputDecoration( decoration: InputDecoration(

View File

@@ -23,7 +23,7 @@ class CompletionReviewSearchAndTabs extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4), padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF1F5F9), color: UiColors.muted,
borderRadius: UiConstants.radiusMd, borderRadius: UiConstants.radiusMd,
), ),
child: TextField( child: TextField(
@@ -69,17 +69,17 @@ class CompletionReviewSearchAndTabs extends StatelessWidget {
child: Container( child: Container(
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? const Color(0xFF2563EB) : Colors.white, color: isSelected ? UiColors.primary : UiColors.white,
borderRadius: UiConstants.radiusMd, borderRadius: UiConstants.radiusMd,
border: Border.all( border: Border.all(
color: isSelected ? const Color(0xFF2563EB) : UiColors.border, color: isSelected ? UiColors.primary : UiColors.border,
), ),
), ),
child: Center( child: Center(
child: Text( child: Text(
text, text,
style: UiTypography.body2b.copyWith( style: UiTypography.body2b.copyWith(
color: isSelected ? Colors.white : UiColors.textSecondary, color: isSelected ? UiColors.primaryForeground : UiColors.textSecondary,
), ),
), ),
), ),

View File

@@ -15,7 +15,7 @@ class InvoicesListSkeleton extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.all(UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space5),
child: Column( child: Column(
children: List.generate(4, (int index) { children: List<Widget>.generate(4, (int index) {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space4), padding: const EdgeInsets.only(bottom: UiConstants.space4),
child: Container( child: Container(
@@ -26,10 +26,10 @@ class InvoicesListSkeleton extends StatelessWidget {
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: <Widget>[
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: <Widget>[
UiShimmerBox( UiShimmerBox(
width: 64, width: 64,
height: 22, height: 22,
@@ -47,10 +47,10 @@ class InvoicesListSkeleton extends StatelessWidget {
const SizedBox(height: UiConstants.space3), const SizedBox(height: UiConstants.space3),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: <Widget>[
const Column( const Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: <Widget>[
UiShimmerLine(width: 80, height: 10), UiShimmerLine(width: 80, height: 10),
SizedBox(height: UiConstants.space1), SizedBox(height: UiConstants.space1),
UiShimmerLine(width: 100, height: 20), UiShimmerLine(width: 100, height: 20),

View File

@@ -33,7 +33,7 @@ class PendingInvoicesSection extends StatelessWidget {
width: 8, width: 8,
height: 8, height: 8,
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: Colors.orange, color: UiColors.textWarning,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
), ),
@@ -101,7 +101,7 @@ class PendingInvoiceCard extends StatelessWidget {
final DateFormat formatter = DateFormat('EEEE, MMMM d'); final DateFormat formatter = DateFormat('EEEE, MMMM d');
final String dateLabel = invoice.dueDate != null final String dateLabel = invoice.dueDate != null
? formatter.format(invoice.dueDate!) ? formatter.format(invoice.dueDate!)
: 'N/A'; : 'N/A'; // TODO: localize
final double amountDollars = invoice.amountCents / 100.0; final double amountDollars = invoice.amountCents / 100.0;
return Container( return Container(

View File

@@ -3,7 +3,7 @@ import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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/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/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_coverage_stats_usecase.dart';
import 'package:client_coverage/src/domain/usecases/get_shifts_for_date_usecase.dart'; import 'package:client_coverage/src/domain/usecases/get_shifts_for_date_usecase.dart';
@@ -21,8 +21,8 @@ class CoverageModule extends Module {
@override @override
void binds(Injector i) { void binds(Injector i) {
// Repositories // Repositories
i.addLazySingleton<CoverageRepository>( i.addLazySingleton<CoverageRepositoryInterface>(
() => CoverageRepositoryImpl(apiService: i.get<BaseApiService>()), () => CoverageRepositoryInterfaceImpl(apiService: i.get<BaseApiService>()),
); );
// Use Cases // Use Cases

View File

@@ -1,14 +1,14 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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. /// Uses [BaseApiService] with [ClientEndpoints] for all backend access.
class CoverageRepositoryImpl implements CoverageRepository { class CoverageRepositoryInterfaceImpl implements CoverageRepositoryInterface {
/// Creates a [CoverageRepositoryImpl]. /// Creates a [CoverageRepositoryInterfaceImpl].
CoverageRepositoryImpl({required BaseApiService apiService}) CoverageRepositoryInterfaceImpl({required BaseApiService apiService})
: _apiService = apiService; : _apiService = apiService;
final BaseApiService _apiService; final BaseApiService _apiService;

View File

@@ -4,7 +4,7 @@ import 'package:krow_domain/krow_domain.dart';
/// ///
/// Defines the contract for accessing coverage data via the V2 REST API, /// Defines the contract for accessing coverage data via the V2 REST API,
/// acting as a boundary between the Domain and Data layers. /// 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]. /// Fetches shifts with assigned workers for a specific [date].
Future<List<ShiftWithWorkers>> getShiftsForDate({required DateTime date}); Future<List<ShiftWithWorkers>> getShiftsForDate({required DateTime date});

View File

@@ -1,17 +1,17 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:client_coverage/src/domain/arguments/cancel_late_worker_arguments.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. /// 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 class CancelLateWorkerUseCase
implements UseCase<CancelLateWorkerArguments, void> { implements UseCase<CancelLateWorkerArguments, void> {
/// Creates a [CancelLateWorkerUseCase]. /// Creates a [CancelLateWorkerUseCase].
CancelLateWorkerUseCase(this._repository); CancelLateWorkerUseCase(this._repository);
final CoverageRepository _repository; final CoverageRepositoryInterface _repository;
@override @override
Future<void> call(CancelLateWorkerArguments arguments) { Future<void> call(CancelLateWorkerArguments arguments) {

View File

@@ -2,17 +2,17 @@ import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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/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. /// 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 class GetCoverageStatsUseCase
implements UseCase<GetCoverageStatsArguments, CoverageStats> { implements UseCase<GetCoverageStatsArguments, CoverageStats> {
/// Creates a [GetCoverageStatsUseCase]. /// Creates a [GetCoverageStatsUseCase].
GetCoverageStatsUseCase(this._repository); GetCoverageStatsUseCase(this._repository);
final CoverageRepository _repository; final CoverageRepositoryInterface _repository;
@override @override
Future<CoverageStats> call(GetCoverageStatsArguments arguments) { Future<CoverageStats> call(GetCoverageStatsArguments arguments) {

View File

@@ -2,17 +2,17 @@ import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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/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. /// 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 class GetShiftsForDateUseCase
implements UseCase<GetShiftsForDateArguments, List<ShiftWithWorkers>> { implements UseCase<GetShiftsForDateArguments, List<ShiftWithWorkers>> {
/// Creates a [GetShiftsForDateUseCase]. /// Creates a [GetShiftsForDateUseCase].
GetShiftsForDateUseCase(this._repository); GetShiftsForDateUseCase(this._repository);
final CoverageRepository _repository; final CoverageRepositoryInterface _repository;
@override @override
Future<List<ShiftWithWorkers>> call(GetShiftsForDateArguments arguments) { Future<List<ShiftWithWorkers>> call(GetShiftsForDateArguments arguments) {

View File

@@ -1,17 +1,17 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:client_coverage/src/domain/arguments/submit_worker_review_arguments.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. /// 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 class SubmitWorkerReviewUseCase
implements UseCase<SubmitWorkerReviewArguments, void> { implements UseCase<SubmitWorkerReviewArguments, void> {
/// Creates a [SubmitWorkerReviewUseCase]. /// Creates a [SubmitWorkerReviewUseCase].
SubmitWorkerReviewUseCase(this._repository); SubmitWorkerReviewUseCase(this._repository);
final CoverageRepository _repository; final CoverageRepositoryInterface _repository;
@override @override
Future<void> call(SubmitWorkerReviewArguments arguments) async { Future<void> call(SubmitWorkerReviewArguments arguments) async {

View File

@@ -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/blocs/coverage_state.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_calendar_selector.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_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_shift_list.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_stats_header.dart'; import 'package:client_coverage/src/presentation/widgets/coverage_stats_header.dart';
import 'package:client_coverage/src/presentation/widgets/late_workers_alert.dart'; import 'package:client_coverage/src/presentation/widgets/late_workers_alert.dart';
/// Page for displaying daily coverage information. /// 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 { class CoveragePage extends StatefulWidget {
/// Creates a [CoveragePage]. /// Creates a [CoveragePage].
const CoveragePage({super.key}); const CoveragePage({super.key});
@@ -27,14 +27,13 @@ class CoveragePage extends StatefulWidget {
} }
class _CoveragePageState extends State<CoveragePage> { class _CoveragePageState extends State<CoveragePage> {
/// Controller for the [CustomScrollView].
late ScrollController _scrollController; late ScrollController _scrollController;
bool _isScrolled = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scrollController = ScrollController(); _scrollController = ScrollController();
_scrollController.addListener(_onScroll);
} }
@override @override
@@ -43,16 +42,6 @@ class _CoveragePageState extends State<CoveragePage> {
super.dispose(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<CoverageBloc>( return BlocProvider<CoverageBloc>(
@@ -69,6 +58,21 @@ class _CoveragePageState extends State<CoveragePage> {
type: UiSnackbarType.error, 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) { builder: (BuildContext context, CoverageState state) {
final DateTime selectedDate = state.selectedDate ?? DateTime.now(); final DateTime selectedDate = state.selectedDate ?? DateTime.now();
@@ -78,19 +82,26 @@ class _CoveragePageState extends State<CoveragePage> {
slivers: <Widget>[ slivers: <Widget>[
SliverAppBar( SliverAppBar(
pinned: true, pinned: true,
expandedHeight: 300.0, expandedHeight: 316.0,
backgroundColor: UiColors.primary, backgroundColor: UiColors.primary,
title: AnimatedSwitcher( title: Column(
duration: const Duration(milliseconds: 200), crossAxisAlignment: CrossAxisAlignment.start,
child: Text( mainAxisSize: MainAxisSize.min,
_isScrolled children: <Widget>[
? DateFormat('MMMM d').format(selectedDate) Text(
: context.t.client_coverage.page.daily_coverage, context.t.client_coverage.page.daily_coverage,
key: ValueKey<bool>(_isScrolled),
style: UiTypography.title2m.copyWith( style: UiTypography.title2m.copyWith(
color: UiColors.primaryForeground, color: UiColors.primaryForeground,
), ),
), ),
Text(
DateFormat('EEEE, MMMM d').format(selectedDate),
style: UiTypography.body3r.copyWith(
color: UiColors.primaryForeground
.withValues(alpha: 0.6),
),
),
],
), ),
actions: <Widget>[ actions: <Widget>[
IconButton( IconButton(
@@ -117,10 +128,13 @@ class _CoveragePageState extends State<CoveragePage> {
], ],
flexibleSpace: Container( flexibleSpace: Container(
decoration: const BoxDecoration( 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( gradient: LinearGradient(
colors: <Color>[ colors: <Color>[
UiColors.primary, UiColors.primary,
UiColors.primary, Color(0xFF0626A8),
], ],
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
@@ -154,6 +168,12 @@ class _CoveragePageState extends State<CoveragePage> {
state.stats?.totalPositionsConfirmed ?? 0, state.stats?.totalPositionsConfirmed ?? 0,
totalNeeded: totalNeeded:
state.stats?.totalPositionsNeeded ?? 0, 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<CoveragePage> {
); );
} }
/// 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({ Widget _buildBody({
required BuildContext context, required BuildContext context,
required CoverageState state, required CoverageState state,
@@ -226,9 +249,6 @@ class _CoveragePageState extends State<CoveragePage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
spacing: UiConstants.space6, spacing: UiConstants.space6,
children: <Widget>[
Column(
spacing: UiConstants.space2,
children: <Widget>[ children: <Widget>[
if (state.stats != null && if (state.stats != null &&
state.stats!.totalWorkersLate > 0) ...<Widget>[ state.stats!.totalWorkersLate > 0) ...<Widget>[
@@ -236,15 +256,13 @@ class _CoveragePageState extends State<CoveragePage> {
lateCount: state.stats!.totalWorkersLate, lateCount: state.stats!.totalWorkersLate,
), ),
], ],
if (state.stats != null) ...<Widget>[
CoverageQuickStats(stats: state.stats!),
],
],
),
Text( Text(
'${context.t.client_coverage.page.shifts} (${state.shifts.length})', context.t.client_coverage.page.live_activity,
style: UiTypography.title2b.copyWith( style: UiTypography.body4m.copyWith(
color: UiColors.textPrimary, color: UiColors.textSecondary,
letterSpacing: 2.0,
fontWeight: FontWeight.w900,
fontSize: 10,
), ),
), ),
CoverageShiftList(shifts: state.shifts), CoverageShiftList(shifts: state.shifts),

View File

@@ -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<CoverageBloc>();
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(UiConstants.space4),
),
),
builder: (_) => BlocProvider<CoverageBloc>.value(
value: bloc,
child: CancelLateWorkerSheet(worker: worker),
),
);
}
@override
State<CancelLateWorkerSheet> createState() => _CancelLateWorkerSheetState();
}
class _CancelLateWorkerSheetState extends State<CancelLateWorkerSheet> {
/// 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: <Widget>[
// 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: <Widget>[
Row(
spacing: UiConstants.space3,
children: <Widget>[
const Icon(
UiIcons.warning,
color: UiColors.destructive,
size: 28,
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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: <Widget>[
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<CoverageBloc>().add(
CoverageCancelLateWorkerRequested(
assignmentId: widget.worker.assignmentId,
reason: reason.isNotEmpty ? reason : null,
),
);
Navigator.of(context).pop();
}
}

View File

@@ -110,7 +110,7 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected
? UiColors.primaryForeground ? UiColors.primaryForeground
: UiColors.primaryForeground.withOpacity(0.1), : UiColors.primaryForeground.withAlpha(25),
borderRadius: UiConstants.radiusLg, borderRadius: UiConstants.radiusLg,
border: isToday && !isSelected border: isToday && !isSelected
? Border.all( ? Border.all(
@@ -122,6 +122,14 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[ children: <Widget>[
Text(
DateFormat('E').format(date),
style: UiTypography.body4m.copyWith(
color: isSelected
? UiColors.primary
: UiColors.primaryForeground.withAlpha(179),
),
),
Text( Text(
date.day.toString().padLeft(2, '0'), date.day.toString().padLeft(2, '0'),
style: UiTypography.body1b.copyWith( style: UiTypography.body1b.copyWith(
@@ -130,14 +138,6 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
: UiColors.primaryForeground, : UiColors.primaryForeground,
), ),
), ),
Text(
DateFormat('E').format(date),
style: UiTypography.body4m.copyWith(
color: isSelected
? UiColors.mutedForeground
: UiColors.primaryForeground.withOpacity(0.7),
),
),
], ],
), ),
), ),

View File

@@ -5,40 +5,30 @@ import 'package:client_coverage/src/presentation/widgets/coverage_page_skeleton/
/// Shimmer loading skeleton that mimics the coverage page loaded layout. /// Shimmer loading skeleton that mimics the coverage page loaded layout.
/// ///
/// Shows placeholder shapes for the quick stats row, shift section header, /// Shows placeholder shapes for the live activity section label and a list
/// and a list of shift cards with worker rows. /// of shift cards with worker rows.
class CoveragePageSkeleton extends StatelessWidget { class CoveragePageSkeleton extends StatelessWidget {
/// Creates a [CoveragePageSkeleton]. /// Creates a [CoveragePageSkeleton].
const CoveragePageSkeleton({super.key}); const CoveragePageSkeleton({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return UiShimmer( return const UiShimmer(
child: Padding( child: Padding(
padding: const EdgeInsets.all(UiConstants.space5), padding: EdgeInsets.all(UiConstants.space5),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: <Widget>[
// Quick stats row (2 stat cards) // "LIVE ACTIVITY" section label placeholder
const Row( UiShimmerLine(width: 100, height: 10),
children: [ SizedBox(height: UiConstants.space6),
Expanded(child: UiShimmerStatsCard()),
SizedBox(width: UiConstants.space2),
Expanded(child: UiShimmerStatsCard()),
],
),
const SizedBox(height: UiConstants.space6),
// Shifts section header
const UiShimmerLine(width: 140, height: 18),
const SizedBox(height: UiConstants.space6),
// Shift cards with worker rows // Shift cards with worker rows
const ShiftCardSkeleton(), ShiftCardSkeleton(),
const SizedBox(height: UiConstants.space3), SizedBox(height: UiConstants.space3),
const ShiftCardSkeleton(), ShiftCardSkeleton(),
const SizedBox(height: UiConstants.space3), SizedBox(height: UiConstants.space3),
const ShiftCardSkeleton(), ShiftCardSkeleton(),
], ],
), ),
), ),

View File

@@ -15,19 +15,19 @@ class ShiftCardSkeleton extends StatelessWidget {
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: Column( child: Column(
children: [ children: <Widget>[
// Shift header // Shift header
Padding( Padding(
padding: const EdgeInsets.all(UiConstants.space4), padding: const EdgeInsets.all(UiConstants.space4),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: <Widget>[
const UiShimmerLine(width: 180, height: 16), const UiShimmerLine(width: 180, height: 16),
const SizedBox(height: UiConstants.space2), const SizedBox(height: UiConstants.space2),
const UiShimmerLine(width: 120, height: 12), const UiShimmerLine(width: 120, height: 12),
const SizedBox(height: UiConstants.space2), const SizedBox(height: UiConstants.space2),
Row( Row(
children: [ children: <Widget>[
const UiShimmerLine(width: 80, height: 12), const UiShimmerLine(width: 80, height: 12),
const Spacer(), const Spacer(),
UiShimmerBox( UiShimmerBox(
@@ -47,7 +47,7 @@ class ShiftCardSkeleton extends StatelessWidget {
horizontal: UiConstants.space3, horizontal: UiConstants.space3,
).copyWith(bottom: UiConstants.space3), ).copyWith(bottom: UiConstants.space3),
child: const Column( child: const Column(
children: [ children: <Widget>[
UiShimmerListItem(), UiShimmerListItem(),
UiShimmerListItem(), UiShimmerListItem(),
], ],

View File

@@ -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: <Widget>[
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,
),
),
],
);
}
}

View File

@@ -4,13 +4,17 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.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/shift_header.dart';
import 'package:client_coverage/src/presentation/widgets/worker_row.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. /// Each shift is rendered as a card with a tappable [ShiftHeader] that toggles
class CoverageShiftList extends StatelessWidget { /// 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]. /// Creates a [CoverageShiftList].
const CoverageShiftList({ const CoverageShiftList({
required this.shifts, required this.shifts,
@@ -20,17 +24,73 @@ class CoverageShiftList extends StatelessWidget {
/// The list of shifts to display. /// The list of shifts to display.
final List<ShiftWithWorkers> shifts; final List<ShiftWithWorkers> shifts;
@override
State<CoverageShiftList> createState() => _CoverageShiftListState();
}
/// State for [CoverageShiftList] managing which shift cards are expanded.
class _CoverageShiftListState extends State<CoverageShiftList> {
/// Set of shift IDs whose cards are currently expanded.
final Set<String> _expandedShiftIds = <String>{};
/// 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). /// Formats a [DateTime] to a readable time string (h:mm a).
String _formatTime(DateTime? time) { String _formatTime(DateTime? time) {
if (time == null) return ''; if (time == null) return '';
return DateFormat('h:mm a').format(time); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_ensureInitialised();
final TranslationsClientCoverageEn l10n = context.t.client_coverage; final TranslationsClientCoverageEn l10n = context.t.client_coverage;
if (shifts.isEmpty) { if (widget.shifts.isEmpty) {
return Container( return Container(
padding: const EdgeInsets.all(UiConstants.space8), padding: const EdgeInsets.all(UiConstants.space8),
width: double.infinity, width: double.infinity,
@@ -57,53 +117,85 @@ class CoverageShiftList extends StatelessWidget {
} }
return Column( return Column(
children: shifts.map((ShiftWithWorkers shift) { children: widget.shifts.map((ShiftWithWorkers shift) {
final int coveragePercent = shift.requiredWorkerCount > 0 final int coveragePercent = shift.requiredWorkerCount > 0
? ((shift.assignedWorkerCount / shift.requiredWorkerCount) * 100) ? ((shift.assignedWorkerCount / shift.requiredWorkerCount) * 100)
.round() .round()
: 0; : 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( return Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3), margin: const EdgeInsets.only(bottom: UiConstants.space3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UiColors.bgPopup, color: UiColors.bgPopup,
borderRadius: UiConstants.radiusLg, borderRadius: UiConstants.radius2xl,
border: Border.all(color: UiColors.border), boxShadow: <BoxShadow>[
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: Column( child: Column(
children: <Widget>[ children: <Widget>[
ShiftHeader( ShiftHeader(
title: shift.roleName, title: shift.roleName,
location: '', // V2 API does not return location on coverage
startTime: _formatTime(shift.timeRange.startsAt), startTime: _formatTime(shift.timeRange.startsAt),
current: shift.assignedWorkerCount, current: shift.assignedWorkerCount,
total: shift.requiredWorkerCount, total: shift.requiredWorkerCount,
coveragePercent: coveragePercent, coveragePercent: coveragePercent,
shiftId: shift.shiftId, shiftId: shift.shiftId,
onSiteCount: onSite,
enRouteCount: enRoute,
lateCount: lateCount,
isExpanded: isExpanded,
onToggle: () => _toggleShift(shift.shiftId),
), ),
if (shift.assignedWorkers.isNotEmpty) AnimatedCrossFade(
Padding( firstChild: const SizedBox.shrink(),
padding: const EdgeInsets.all(UiConstants.space3), secondChild: _buildWorkerSection(shift, l10n),
child: Column( crossFadeState: isExpanded
children: shift.assignedWorkers ? CrossFadeState.showSecond
.map<Widget>((AssignedWorker worker) { : CrossFadeState.showFirst,
final bool isLast = duration: const Duration(milliseconds: 200),
worker == shift.assignedWorkers.last;
return Padding(
padding: EdgeInsets.only(
bottom: isLast ? 0 : UiConstants.space2,
), ),
child: WorkerRow( ],
worker: worker,
shiftStartTime:
_formatTime(shift.timeRange.startsAt),
), ),
); );
}).toList(), }).toList(),
), );
) }
else
/// Builds the expanded worker section for a shift including divider.
Widget _buildWorkerSection(
ShiftWithWorkers shift,
TranslationsClientCoverageEn l10n,
) {
if (shift.assignedWorkers.isEmpty) {
return Column(
children: <Widget>[
const Divider(height: 1, color: UiColors.border),
Padding( Padding(
padding: const EdgeInsets.all(UiConstants.space4), padding: const EdgeInsets.all(UiConstants.space4),
child: Text( child: Text(
@@ -114,9 +206,48 @@ class CoverageShiftList extends StatelessWidget {
), ),
), ),
], ],
);
}
return Column(
children: <Widget>[
const Divider(height: 1, color: UiColors.border),
Padding(
padding: const EdgeInsets.all(UiConstants.space3),
child: Column(
children:
shift.assignedWorkers.map<Widget>((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(), }).toList(),
),
),
],
); );
} }
} }

View File

@@ -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: <Widget>[
Icon(
icon,
color: color,
size: UiConstants.space6,
),
Text(
value,
style: UiTypography.title1b.copyWith(
color: color,
),
),
Text(
label,
style: UiTypography.body3r.copyWith(
color: color,
),
),
],
),
);
}
}

View File

@@ -2,43 +2,77 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.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 { class CoverageStatsHeader extends StatelessWidget {
/// Creates a [CoverageStatsHeader]. /// Creates a [CoverageStatsHeader] with coverage and worker status data.
const CoverageStatsHeader({ const CoverageStatsHeader({
required this.coveragePercent, required this.coveragePercent,
required this.totalConfirmed, required this.totalConfirmed,
required this.totalNeeded, required this.totalNeeded,
required this.totalCheckedIn,
required this.totalEnRoute,
required this.totalLate,
super.key, super.key,
}); });
/// The current coverage percentage. /// The current overall coverage percentage (0-100).
final double coveragePercent; final double coveragePercent;
/// The number of confirmed workers. /// The number of confirmed workers.
final int totalConfirmed; final int totalConfirmed;
/// The total number of workers needed. /// The total number of workers needed for full coverage.
final int totalNeeded; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
padding: const EdgeInsets.all(UiConstants.space4), padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UiColors.primaryForeground.withOpacity(0.1), color: UiColors.primaryForeground.withValues(alpha: 0.12),
borderRadius: UiConstants.radiusLg, borderRadius: UiConstants.radiusXl,
), ),
child: Row( child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
Column( Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
context.t.client_coverage.page.coverage_status, context.t.client_coverage.page.overall_coverage,
style: UiTypography.body2r.copyWith( style: UiTypography.body3r.copyWith(
color: UiColors.primaryForeground.withOpacity(0.7), color: UiColors.primaryForeground.withValues(alpha: 0.6),
), ),
), ),
Text( Text(
@@ -48,25 +82,95 @@ class CoverageStatsHeader extends StatelessWidget {
), ),
), ),
], ],
), );
Column( }
/// Builds the right column with on-site, en-route, and late stat items.
Widget _buildStatusColumn(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
_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: <Widget>[ children: <Widget>[
Text( Text(
context.t.client_coverage.page.workers, value.toString(),
style: UiTypography.body2r.copyWith( style: UiTypography.title2b.copyWith(
color: UiColors.primaryForeground.withOpacity(0.7), color: valueColor,
), ),
), ),
const SizedBox(width: UiConstants.space2),
Text( Text(
'$totalConfirmed/$totalNeeded', label,
style: UiTypography.title2m.copyWith( 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: <Widget>[
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, color: UiColors.primaryForeground,
borderRadius: UiConstants.radiusFull,
),
), ),
), ),
], ],
), ),
],
), ),
); );
} }

View File

@@ -2,38 +2,54 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.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 { class LateWorkersAlert extends StatelessWidget {
/// Creates a [LateWorkersAlert]. /// Creates a [LateWorkersAlert] with the given [lateCount].
const LateWorkersAlert({ const LateWorkersAlert({
required this.lateCount, required this.lateCount,
super.key, super.key,
}); });
/// The number of late workers. /// The number of workers currently marked as late.
final int lateCount; final int lateCount;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
padding: const EdgeInsets.all(UiConstants.space3), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration( horizontal: UiConstants.space4,
color: UiColors.destructive.withValues(alpha: 0.1), vertical: UiConstants.space3,
borderRadius: UiConstants.radiusLg,
border: Border.all(
color: UiColors.destructive,
width: 0.5,
), ),
decoration: BoxDecoration(
color: UiColors.destructive,
borderRadius: UiConstants.radiusLg,
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.destructive.withValues(alpha: 0.2),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
), ),
child: Row( child: Row(
spacing: UiConstants.space4,
children: <Widget>[ children: <Widget>[
const Icon( Container(
UiIcons.warning, width: 32,
color: UiColors.destructive, 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( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -41,12 +57,14 @@ class LateWorkersAlert extends StatelessWidget {
Text( Text(
context.t.client_coverage.alert context.t.client_coverage.alert
.workers_running_late(n: lateCount, count: lateCount), .workers_running_late(n: lateCount, count: lateCount),
style: UiTypography.body1b.textError, style: UiTypography.body1b.copyWith(
color: Colors.white,
),
), ),
Text( Text(
context.t.client_coverage.alert.auto_backup_searching, context.t.client_coverage.alert.auto_backup_searching,
style: UiTypography.body3r.copyWith( style: UiTypography.body3r.copyWith(
color: UiColors.textError.withValues(alpha: 0.7), color: Colors.white.withValues(alpha: 0.8),
), ),
), ),
], ],

View File

@@ -1,124 +1,198 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_badge.dart'; /// Tappable header for a collapsible shift card.
///
/// Header section for a shift card showing title, location, time, and coverage. /// 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 { class ShiftHeader extends StatelessWidget {
/// Creates a [ShiftHeader]. /// Creates a [ShiftHeader].
const ShiftHeader({ const ShiftHeader({
required this.title, required this.title,
required this.location,
required this.startTime, required this.startTime,
required this.current, required this.current,
required this.total, required this.total,
required this.coveragePercent, required this.coveragePercent,
required this.shiftId, required this.shiftId,
required this.onSiteCount,
required this.enRouteCount,
required this.lateCount,
required this.isExpanded,
required this.onToggle,
super.key, super.key,
}); });
/// The shift title. /// The shift role or title.
final String title; final String title;
/// The shift location. /// Formatted shift start time (e.g. "8:00 AM").
final String location;
/// The formatted shift start time.
final String startTime; final String startTime;
/// Current number of assigned workers. /// Current number of assigned workers.
final int current; final int current;
/// Total workers needed for the shift. /// Total workers required for the shift.
final int total; final int total;
/// Coverage percentage (0-100+). /// Coverage percentage (0-100+).
final int coveragePercent; final int coveragePercent;
/// The shift identifier. /// Unique shift identifier.
final String shiftId; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( 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), 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: <Widget>[
Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
spacing: UiConstants.space2,
children: <Widget>[ children: <Widget>[
// Row 1: status dot, title + time, badge, chevron.
Row( Row(
spacing: UiConstants.space2, crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[ children: <Widget>[
Container( // Status dot.
width: UiConstants.space2, Padding(
height: UiConstants.space2, padding: const EdgeInsets.only(top: UiConstants.space1),
decoration: const BoxDecoration( child: Container(
color: UiColors.primary, width: 10,
height: 10,
decoration: BoxDecoration(
color: statusColor,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
), ),
),
const SizedBox(width: UiConstants.space4),
// Title and start time.
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text( Text(
title, title,
style: UiTypography.body1b.textPrimary, style: UiTypography.body1b.textPrimary,
), ),
], const SizedBox(height: UiConstants.space1),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row( Row(
spacing: UiConstants.space1,
children: <Widget>[
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: <Widget>[ children: <Widget>[
const Icon( const Icon(
UiIcons.clock, UiIcons.clock,
size: UiConstants.space3, size: 10,
color: UiColors.iconSecondary, color: UiColors.textSecondary,
), ),
const SizedBox(width: 4),
Text( Text(
startTime, 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,
),
],
),
const SizedBox(height: UiConstants.space3),
// Progress bar.
ClipRRect(
borderRadius: UiConstants.radiusFull,
child: SizedBox(
height: 8,
width: double.infinity,
child: Stack(
children: <Widget>[
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, style: UiTypography.body3r.textSecondary,
), ),
], ],
), ),
],
),
],
),
),
CoverageBadge(
current: current,
total: total,
coveragePercent: coveragePercent,
),
],
), ),
); );
} }

View File

@@ -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<CoverageBloc>();
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(UiConstants.space4),
),
),
builder: (_) => BlocProvider<CoverageBloc>.value(
value: bloc,
child: WorkerReviewSheet(worker: worker),
),
);
}
@override
State<WorkerReviewSheet> createState() => _WorkerReviewSheetState();
}
class _WorkerReviewSheetState extends State<WorkerReviewSheet> {
int _rating = 0;
bool _isFavorite = false;
bool _isBlocked = false;
final Set<ReviewIssueFlag> _selectedFlags = <ReviewIssueFlag>{};
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<String> ratingLabels = <String>[
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: <Widget>[
_buildDragHandle(),
const SizedBox(height: UiConstants.space4),
_buildHeader(context, l10n),
const SizedBox(height: UiConstants.space5),
Flexible(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_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<CoverageBloc, CoverageState>(
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: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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<String> ratingLabels) {
return Column(
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List<Widget>.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) ...<Widget>[
const SizedBox(height: UiConstants.space2),
Text(
ratingLabels[_rating - 1],
style: UiTypography.body2r.textSecondary,
textAlign: TextAlign.center,
),
],
],
);
}
Widget _buildToggles(TranslationsClientCoverageReviewEn l10n) {
return Row(
children: <Widget>[
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: <Widget>[
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<ReviewIssueFlag, String> flagLabels =
<ReviewIssueFlag, String>{
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<CoverageBloc>().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();
}
}

View File

@@ -10,6 +10,10 @@ class WorkerRow extends StatelessWidget {
const WorkerRow({ const WorkerRow({
required this.worker, required this.worker,
required this.shiftStartTime, required this.shiftStartTime,
this.showRateButton = false,
this.showCancelButton = false,
this.onRate,
this.onCancel,
super.key, super.key,
}); });
@@ -19,6 +23,18 @@ class WorkerRow extends StatelessWidget {
/// The formatted shift start time. /// The formatted shift start time.
final String shiftStartTime; 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). /// Formats a [DateTime] to a readable time string (h:mm a).
String _formatCheckInTime(DateTime? time) { String _formatCheckInTime(DateTime? time) {
if (time == null) return ''; if (time == null) return '';
@@ -35,10 +51,6 @@ class WorkerRow extends StatelessWidget {
Color textColor; Color textColor;
IconData icon; IconData icon;
String statusText; String statusText;
Color badgeBg;
Color badgeText;
Color badgeBorder;
String badgeLabel;
switch (worker.status) { switch (worker.status) {
case AssignmentStatus.checkedIn: case AssignmentStatus.checkedIn:
@@ -50,10 +62,6 @@ class WorkerRow extends StatelessWidget {
statusText = l10n.status_checked_in_at( statusText = l10n.status_checked_in_at(
time: _formatCheckInTime(worker.checkInAt), time: _formatCheckInTime(worker.checkInAt),
); );
badgeBg = UiColors.textSuccess.withAlpha(40);
badgeText = UiColors.textSuccess;
badgeBorder = badgeText;
badgeLabel = l10n.status_on_site;
case AssignmentStatus.accepted: case AssignmentStatus.accepted:
if (worker.checkInAt == null) { if (worker.checkInAt == null) {
bg = UiColors.textWarning.withAlpha(26); bg = UiColors.textWarning.withAlpha(26);
@@ -62,10 +70,6 @@ class WorkerRow extends StatelessWidget {
textColor = UiColors.textWarning; textColor = UiColors.textWarning;
icon = UiIcons.clock; icon = UiIcons.clock;
statusText = l10n.status_en_route_expected(time: shiftStartTime); statusText = l10n.status_en_route_expected(time: shiftStartTime);
badgeBg = UiColors.textWarning.withAlpha(40);
badgeText = UiColors.textWarning;
badgeBorder = badgeText;
badgeLabel = l10n.status_en_route;
} else { } else {
bg = UiColors.muted.withAlpha(26); bg = UiColors.muted.withAlpha(26);
border = UiColors.border; border = UiColors.border;
@@ -73,10 +77,6 @@ class WorkerRow extends StatelessWidget {
textColor = UiColors.textSecondary; textColor = UiColors.textSecondary;
icon = UiIcons.success; icon = UiIcons.success;
statusText = l10n.status_confirmed; statusText = l10n.status_confirmed;
badgeBg = UiColors.textSecondary.withAlpha(40);
badgeText = UiColors.textSecondary;
badgeBorder = badgeText;
badgeLabel = l10n.status_confirmed;
} }
case AssignmentStatus.noShow: case AssignmentStatus.noShow:
bg = UiColors.destructive.withAlpha(26); bg = UiColors.destructive.withAlpha(26);
@@ -85,10 +85,6 @@ class WorkerRow extends StatelessWidget {
textColor = UiColors.destructive; textColor = UiColors.destructive;
icon = UiIcons.warning; icon = UiIcons.warning;
statusText = l10n.status_no_show; statusText = l10n.status_no_show;
badgeBg = UiColors.destructive.withAlpha(40);
badgeText = UiColors.destructive;
badgeBorder = badgeText;
badgeLabel = l10n.status_no_show;
case AssignmentStatus.checkedOut: case AssignmentStatus.checkedOut:
bg = UiColors.muted.withAlpha(26); bg = UiColors.muted.withAlpha(26);
border = UiColors.border; border = UiColors.border;
@@ -96,10 +92,6 @@ class WorkerRow extends StatelessWidget {
textColor = UiColors.textSecondary; textColor = UiColors.textSecondary;
icon = UiIcons.success; icon = UiIcons.success;
statusText = l10n.status_checked_out; statusText = l10n.status_checked_out;
badgeBg = UiColors.textSecondary.withAlpha(40);
badgeText = UiColors.textSecondary;
badgeBorder = badgeText;
badgeLabel = l10n.status_done;
case AssignmentStatus.completed: case AssignmentStatus.completed:
bg = UiColors.iconSuccess.withAlpha(26); bg = UiColors.iconSuccess.withAlpha(26);
border = UiColors.iconSuccess; border = UiColors.iconSuccess;
@@ -107,10 +99,6 @@ class WorkerRow extends StatelessWidget {
textColor = UiColors.textSuccess; textColor = UiColors.textSuccess;
icon = UiIcons.success; icon = UiIcons.success;
statusText = l10n.status_completed; statusText = l10n.status_completed;
badgeBg = UiColors.textSuccess.withAlpha(40);
badgeText = UiColors.textSuccess;
badgeBorder = badgeText;
badgeLabel = l10n.status_completed;
case AssignmentStatus.assigned: case AssignmentStatus.assigned:
case AssignmentStatus.swapRequested: case AssignmentStatus.swapRequested:
case AssignmentStatus.cancelled: case AssignmentStatus.cancelled:
@@ -121,10 +109,6 @@ class WorkerRow extends StatelessWidget {
textColor = UiColors.textSecondary; textColor = UiColors.textSecondary;
icon = UiIcons.clock; icon = UiIcons.clock;
statusText = worker.status.value; statusText = worker.status.value;
badgeBg = UiColors.textSecondary.withAlpha(40);
badgeText = UiColors.textSecondary;
badgeBorder = badgeText;
badgeLabel = worker.status.value;
} }
return Container( return Container(
@@ -197,21 +181,23 @@ class WorkerRow extends StatelessWidget {
Column( Column(
spacing: UiConstants.space2, spacing: UiConstants.space2,
children: <Widget>[ children: <Widget>[
Container( if (showRateButton && onRate != null)
padding: const EdgeInsets.symmetric( GestureDetector(
horizontal: UiConstants.space2, onTap: onRate,
vertical: UiConstants.space1 / 2, child: UiChip(
label: l10n.actions.rate,
size: UiChipSize.small,
leadingIcon: UiIcons.star,
), ),
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 (showCancelButton && onCancel != null)
GestureDetector(
onTap: onCancel,
child: UiChip(
label: l10n.actions.cancel,
size: UiChipSize.small,
leadingIcon: UiIcons.close,
variant: UiChipVariant.destructive,
), ),
), ),
], ],

View File

@@ -1,20 +1,34 @@
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.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<ClientMainState> 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<ClientMainState>
with BlocErrorHandler<ClientMainState>
implements Disposable {
/// Creates a [ClientMainCubit] and starts listening for route changes.
ClientMainCubit() : super(const ClientMainState()) { ClientMainCubit() : super(const ClientMainState()) {
Modular.to.addListener(_onRouteChanged); Modular.to.addListener(_onRouteChanged);
_onRouteChanged(); _onRouteChanged();
} }
/// Routes that should hide the bottom navigation bar.
static const List<String> _hideBottomBarPaths = <String>[ static const List<String> _hideBottomBarPaths = <String>[
ClientPaths.completionReview, ClientPaths.completionReview,
ClientPaths.awaitingApproval, 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() { void _onRouteChanged() {
if (isClosed) return;
final String path = Modular.to.path; final String path = Modular.to.path;
int newIndex = state.currentIndex; int newIndex = state.currentIndex;
@@ -41,6 +55,9 @@ class ClientMainCubit extends Cubit<ClientMainState> implements Disposable {
} }
} }
/// Navigates to the tab at [index] via Modular safe navigation.
///
/// State update happens automatically via [_onRouteChanged].
void navigateToTab(int index) { void navigateToTab(int index) {
if (index == state.currentIndex) return; if (index == state.currentIndex) return;
@@ -61,7 +78,6 @@ class ClientMainCubit extends Cubit<ClientMainState> implements Disposable {
Modular.to.toClientReports(); Modular.to.toClientReports();
break; break;
} }
// State update will happen via _onRouteChanged
} }
@override @override

View File

@@ -1,14 +1,20 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
/// State for [ClientMainCubit] representing bottom navigation status.
class ClientMainState extends Equatable { class ClientMainState extends Equatable {
/// Creates a [ClientMainState] with the given tab index and bar visibility.
const ClientMainState({ const ClientMainState({
this.currentIndex = 2, // Default to Home this.currentIndex = 2, // Default to Home
this.showBottomBar = true, this.showBottomBar = true,
}); });
/// Index of the currently active bottom navigation tab.
final int currentIndex; final int currentIndex;
/// Whether the bottom navigation bar should be visible.
final bool showBottomBar; final bool showBottomBar;
/// Creates a copy of this state with updated fields.
ClientMainState copyWith({int? currentIndex, bool? showBottomBar}) { ClientMainState copyWith({int? currentIndex, bool? showBottomBar}) {
return ClientMainState( return ClientMainState(
currentIndex: currentIndex ?? this.currentIndex, currentIndex: currentIndex ?? this.currentIndex,

View File

@@ -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_bloc.dart';
import 'package:client_home/src/presentation/blocs/client_home_event.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/blocs/client_home_state.dart';
import 'package:client_home/src/presentation/widgets/dashboard_widget_builder.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. /// Widget that displays the home dashboard in edit mode with drag-and-drop support.
/// ///
/// Allows users to reorder and rearrange dashboard widgets. /// Allows users to reorder and rearrange dashboard widgets.
class ClientHomeEditModeBody extends StatelessWidget { class ClientHomeEditModeBody extends StatelessWidget {
/// Creates a [ClientHomeEditModeBody].
const ClientHomeEditModeBody({required this.state, super.key});
/// The current home state. /// The current home state.
final ClientHomeState state; final ClientHomeState state;
/// Creates a [ClientHomeEditModeBody].
const ClientHomeEditModeBody({
required this.state,
super.key,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ReorderableListView( return ReorderableListView(
@@ -30,18 +26,15 @@ class ClientHomeEditModeBody extends StatelessWidget {
100, 100,
), ),
onReorder: (int oldIndex, int newIndex) { onReorder: (int oldIndex, int newIndex) {
BlocProvider.of<ClientHomeBloc>(context) BlocProvider.of<ClientHomeBloc>(
.add(ClientHomeWidgetReordered(oldIndex, newIndex)); context,
).add(ClientHomeWidgetReordered(oldIndex, newIndex));
}, },
children: state.widgetOrder.map((String id) { children: state.widgetOrder.map((String id) {
return Container( return Container(
key: ValueKey<String>(id), key: ValueKey<String>(id),
margin: const EdgeInsets.only(bottom: UiConstants.space4), margin: const EdgeInsets.only(bottom: UiConstants.space4),
child: DashboardWidgetBuilder( child: DashboardWidgetBuilder(id: id, state: state, isEditMode: true),
id: id,
state: state,
isEditMode: true,
),
); );
}).toList(), }).toList(),
); );

View File

@@ -10,9 +10,9 @@ class ClientHomeHeaderSkeleton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return UiShimmer( return const UiShimmer(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
UiConstants.space4, UiConstants.space4,
UiConstants.space4, UiConstants.space4,
UiConstants.space4, UiConstants.space4,
@@ -23,11 +23,11 @@ class ClientHomeHeaderSkeleton extends StatelessWidget {
children: <Widget>[ children: <Widget>[
Row( Row(
children: <Widget>[ children: <Widget>[
const UiShimmerCircle(size: UiConstants.space10), UiShimmerCircle(size: UiConstants.space10),
const SizedBox(width: UiConstants.space3), SizedBox(width: UiConstants.space3),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: const <Widget>[ children: <Widget>[
UiShimmerLine(width: 80, height: 12), UiShimmerLine(width: 80, height: 12),
SizedBox(height: UiConstants.space1), SizedBox(height: UiConstants.space1),
UiShimmerLine(width: 120, height: 16), UiShimmerLine(width: 120, height: 16),
@@ -37,7 +37,7 @@ class ClientHomeHeaderSkeleton extends StatelessWidget {
), ),
Row( Row(
spacing: UiConstants.space2, spacing: UiConstants.space2,
children: const <Widget>[ children: <Widget>[
UiShimmerBox(width: 36, height: 36), UiShimmerBox(width: 36, height: 36),
UiShimmerBox(width: 36, height: 36), UiShimmerBox(width: 36, height: 36),
], ],

View File

@@ -10,9 +10,9 @@ class ReorderSectionSkeleton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return const Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: const <Widget>[ children: <Widget>[
UiShimmerSectionHeader(), UiShimmerSectionHeader(),
SizedBox(height: UiConstants.space2), SizedBox(height: UiConstants.space2),
SizedBox( SizedBox(

View File

@@ -20,7 +20,8 @@ dependencies:
path: ../../../design_system path: ../../../design_system
core_localization: core_localization:
path: ../../../core_localization path: ../../../core_localization
krow_domain: ^0.0.1 krow_domain:
path: ../../../domain
krow_core: krow_core:
path: ../../../core path: ../../../core

View File

@@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import 'package:client_hubs/src/presentation/blocs/edit_hub/edit_hub_bloc.dart'; import 'package:client_hubs/src/presentation/blocs/edit_hub/edit_hub_bloc.dart';
@@ -38,7 +39,7 @@ class EditHubPage extends StatelessWidget {
message: message, message: message,
type: UiSnackbarType.success, type: UiSnackbarType.success,
); );
Modular.to.pop(true); Modular.to.popSafe(true);
} }
if (state.status == EditHubStatus.failure && if (state.status == EditHubStatus.failure &&
state.errorMessage != null) { state.errorMessage != null) {
@@ -65,7 +66,7 @@ class EditHubPage extends StatelessWidget {
child: HubForm( child: HubForm(
hub: hub, hub: hub,
costCenters: state.costCenters, costCenters: state.costCenters,
onCancel: () => Modular.to.pop(), onCancel: () => Modular.to.popSafe(),
onSave: ({ onSave: ({
required String name, required String name,
required String fullAddress, required String fullAddress,

View File

@@ -38,7 +38,7 @@ class HubDetailsPage extends StatelessWidget {
message: message, message: message,
type: UiSnackbarType.success, 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 && if (state.status == HubDetailsStatus.failure &&
state.errorMessage != null) { state.errorMessage != null) {
@@ -117,7 +117,7 @@ class HubDetailsPage extends StatelessWidget {
Future<void> _navigateToEditPage(BuildContext context) async { Future<void> _navigateToEditPage(BuildContext context) async {
final bool? saved = await Modular.to.toEditHub(hub: hub); final bool? saved = await Modular.to.toEditHub(hub: hub);
if (saved == true && context.mounted) { if (saved == true && context.mounted) {
Modular.to.pop(true); // Return true to indicate change Modular.to.popSafe(true); // Return true to indicate change
} }
} }

View File

@@ -112,7 +112,7 @@ class _HubFormState extends State<HubForm> {
vertical: 16, vertical: 16,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF8FAFD), color: UiColors.muted,
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
UiConstants.radiusBase * 1.5, UiConstants.radiusBase * 1.5,
), ),
@@ -225,7 +225,7 @@ class _HubFormState extends State<HubForm> {
color: UiColors.textSecondary.withValues(alpha: 0.5), color: UiColors.textSecondary.withValues(alpha: 0.5),
), ),
filled: true, filled: true,
fillColor: const Color(0xFFF8FAFD), fillColor: UiColors.muted,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4, horizontal: UiConstants.space4,
vertical: 16, vertical: 16,

View File

@@ -13,7 +13,7 @@ class HubsPageSkeleton extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return UiShimmer( return UiShimmer(
child: Column( child: Column(
children: List.generate(5, (int index) { children: List<Widget>.generate(5, (int index) {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3), padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: Container( child: Container(
@@ -23,7 +23,7 @@ class HubsPageSkeleton extends StatelessWidget {
), ),
padding: const EdgeInsets.all(UiConstants.space4), padding: const EdgeInsets.all(UiConstants.space4),
child: Row( child: Row(
children: [ children: <Widget>[
// Leading icon placeholder // Leading icon placeholder
UiShimmerBox( UiShimmerBox(
width: 52, width: 52,
@@ -35,7 +35,7 @@ class HubsPageSkeleton extends StatelessWidget {
const Expanded( const Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: <Widget>[
UiShimmerLine(width: 160, height: 16), UiShimmerLine(width: 160, height: 16),
SizedBox(height: UiConstants.space2), SizedBox(height: UiConstants.space2),
UiShimmerLine(width: 200, height: 12), UiShimmerLine(width: 200, height: 12),

View File

@@ -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_permanent_order_usecase.dart';
import 'domain/usecases/create_rapid_order_usecase.dart'; import 'domain/usecases/create_rapid_order_usecase.dart';
import 'domain/usecases/create_recurring_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_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/parse_rapid_order_usecase.dart';
import 'domain/usecases/transcribe_rapid_order_usecase.dart'; import 'domain/usecases/transcribe_rapid_order_usecase.dart';
import 'presentation/blocs/index.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(CreateOneTimeOrderUseCase.new);
i.addLazySingleton(CreatePermanentOrderUseCase.new); i.addLazySingleton(CreatePermanentOrderUseCase.new);
i.addLazySingleton(CreateRecurringOrderUseCase.new); i.addLazySingleton(CreateRecurringOrderUseCase.new);
@@ -55,6 +59,12 @@ class ClientCreateOrderModule extends Module {
i.addLazySingleton(ParseRapidOrderTextToOrderUseCase.new); i.addLazySingleton(ParseRapidOrderTextToOrderUseCase.new);
i.addLazySingleton(GetOrderDetailsForReorderUseCase.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 // BLoCs
i.add<RapidOrderBloc>( i.add<RapidOrderBloc>(
() => RapidOrderBloc( () => RapidOrderBloc(
@@ -63,15 +73,36 @@ class ClientCreateOrderModule extends Module {
i.get<AudioRecorderService>(), i.get<AudioRecorderService>(),
), ),
); );
i.add<OneTimeOrderBloc>(OneTimeOrderBloc.new); i.add<OneTimeOrderBloc>(
() => OneTimeOrderBloc(
i.get<CreateOneTimeOrderUseCase>(),
i.get<GetOrderDetailsForReorderUseCase>(),
i.get<GetVendorsUseCase>(),
i.get<GetRolesByVendorUseCase>(),
i.get<GetHubsUseCase>(),
i.get<GetManagersByHubUseCase>(),
),
);
i.add<PermanentOrderBloc>( i.add<PermanentOrderBloc>(
() => PermanentOrderBloc( () => PermanentOrderBloc(
i.get<CreatePermanentOrderUseCase>(), i.get<CreatePermanentOrderUseCase>(),
i.get<GetOrderDetailsForReorderUseCase>(), i.get<GetOrderDetailsForReorderUseCase>(),
i.get<ClientOrderQueryRepositoryInterface>(), i.get<GetVendorsUseCase>(),
i.get<GetRolesByVendorUseCase>(),
i.get<GetHubsUseCase>(),
i.get<GetManagersByHubUseCase>(),
),
);
i.add<RecurringOrderBloc>(
() => RecurringOrderBloc(
i.get<CreateRecurringOrderUseCase>(),
i.get<GetOrderDetailsForReorderUseCase>(),
i.get<GetVendorsUseCase>(),
i.get<GetRolesByVendorUseCase>(),
i.get<GetHubsUseCase>(),
i.get<GetManagersByHubUseCase>(),
), ),
); );
i.add<RecurringOrderBloc>(RecurringOrderBloc.new);
} }
@override @override

View File

@@ -1,15 +1,69 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
/// Arguments for the [CreateOneTimeOrderUseCase]. /// A single position entry for a one-time order submission.
/// class OneTimeOrderPositionArgument extends UseCaseArgument {
/// Wraps the V2 API payload map for a one-time order. /// Creates a [OneTimeOrderPositionArgument].
class OneTimeOrderArguments extends UseCaseArgument { const OneTimeOrderPositionArgument({
/// Creates a [OneTimeOrderArguments] with the given [payload]. required this.roleId,
const OneTimeOrderArguments({required this.payload}); required this.workerCount,
required this.startTime,
required this.endTime,
this.roleName,
this.lunchBreak,
});
/// The V2 API payload map. /// The role ID for this position.
final Map<String, dynamic> payload; 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 @override
List<Object?> get props => <Object?>[payload]; List<Object?> get props =>
<Object?>[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<OneTimeOrderPositionArgument> positions;
/// The selected vendor ID, if applicable.
final String? vendorId;
@override
List<Object?> get props =>
<Object?>[hubId, eventName, orderDate, positions, vendorId];
} }

View File

@@ -1,10 +1,75 @@
/// Arguments for the [CreatePermanentOrderUseCase]. import 'package:krow_core/core.dart';
///
/// Wraps the V2 API payload map for a permanent order.
class PermanentOrderArguments {
/// Creates a [PermanentOrderArguments] with the given [payload].
const PermanentOrderArguments({required this.payload});
/// The V2 API payload map. /// A single position entry for a permanent order submission.
final Map<String, dynamic> payload; 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<Object?> get props =>
<Object?>[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<String> daysOfWeek;
/// The list of position entries.
final List<PermanentOrderPositionArgument> positions;
/// The selected vendor ID, if applicable.
final String? vendorId;
@override
List<Object?> get props => <Object?>[
hubId,
eventName,
startDate,
daysOfWeek,
positions,
vendorId,
];
} }

View File

@@ -1,10 +1,80 @@
/// Arguments for the [CreateRecurringOrderUseCase]. import 'package:krow_core/core.dart';
///
/// Wraps the V2 API payload map for a recurring order.
class RecurringOrderArguments {
/// Creates a [RecurringOrderArguments] with the given [payload].
const RecurringOrderArguments({required this.payload});
/// The V2 API payload map. /// A single position entry for a recurring order submission.
final Map<String, dynamic> payload; 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<Object?> get props =>
<Object?>[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<String> recurringDays;
/// The list of position entries.
final List<RecurringOrderPositionArgument> positions;
/// The selected vendor ID, if applicable.
final String? vendorId;
@override
List<Object?> get props => <Object?>[
hubId,
eventName,
startDate,
endDate,
recurringDays,
positions,
vendorId,
];
} }

View File

@@ -5,16 +5,45 @@ import '../repositories/client_create_order_repository_interface.dart';
/// Use case for creating a one-time staffing order. /// 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 class CreateOneTimeOrderUseCase
implements UseCase<OneTimeOrderArguments, void> { implements UseCase<OneTimeOrderArguments, void> {
/// Creates a [CreateOneTimeOrderUseCase]. /// Creates a [CreateOneTimeOrderUseCase].
const CreateOneTimeOrderUseCase(this._repository); const CreateOneTimeOrderUseCase(this._repository);
/// The create-order repository.
final ClientCreateOrderRepositoryInterface _repository; final ClientCreateOrderRepositoryInterface _repository;
@override @override
Future<void> call(OneTimeOrderArguments input) { Future<void> call(OneTimeOrderArguments input) {
return _repository.createOneTimeOrder(input.payload); final String orderDate = formatDateToIso(input.orderDate);
final List<Map<String, dynamic>> positions =
input.positions.map((OneTimeOrderPositionArgument p) {
return <String, dynamic>{
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<String, dynamic> payload = <String, dynamic>{
'hubId': input.hubId,
'eventName': input.eventName,
'orderDate': orderDate,
'positions': positions,
if (input.vendorId != null) 'vendorId': input.vendorId,
};
return _repository.createOneTimeOrder(payload);
} }
} }

View File

@@ -1,17 +1,61 @@
import 'package:krow_core/core.dart';
import '../arguments/permanent_order_arguments.dart'; import '../arguments/permanent_order_arguments.dart';
import '../repositories/client_create_order_repository_interface.dart'; import '../repositories/client_create_order_repository_interface.dart';
/// Day-of-week labels in Sunday-first order, matching the V2 API convention.
const List<String> _dayLabels = <String>[
'SUN',
'MON',
'TUE',
'WED',
'THU',
'FRI',
'SAT',
];
/// Use case for creating a permanent staffing order. /// Use case for creating a permanent staffing order.
/// ///
/// Delegates the V2 API payload to the repository. /// Builds the V2 API payload from typed [PermanentOrderArguments] and
class CreatePermanentOrderUseCase { /// 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<PermanentOrderArguments, void> {
/// Creates a [CreatePermanentOrderUseCase]. /// Creates a [CreatePermanentOrderUseCase].
const CreatePermanentOrderUseCase(this._repository); const CreatePermanentOrderUseCase(this._repository);
/// The create-order repository.
final ClientCreateOrderRepositoryInterface _repository; final ClientCreateOrderRepositoryInterface _repository;
/// Executes the use case with the given [args]. @override
Future<void> call(PermanentOrderArguments args) { Future<void> call(PermanentOrderArguments input) {
return _repository.createPermanentOrder(args.payload); final String startDate = formatDateToIso(input.startDate);
final List<int> daysOfWeek = input.daysOfWeek
.map((String day) => _dayLabels.indexOf(day) % 7)
.toList();
final List<Map<String, dynamic>> positions =
input.positions.map((PermanentOrderPositionArgument p) {
return <String, dynamic>{
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<String, dynamic> payload = <String, dynamic>{
'hubId': input.hubId,
'eventName': input.eventName,
'startDate': startDate,
'daysOfWeek': daysOfWeek,
'positions': positions,
if (input.vendorId != null) 'vendorId': input.vendorId,
};
return _repository.createPermanentOrder(payload);
} }
} }

View File

@@ -1,17 +1,63 @@
import 'package:krow_core/core.dart';
import '../arguments/recurring_order_arguments.dart'; import '../arguments/recurring_order_arguments.dart';
import '../repositories/client_create_order_repository_interface.dart'; import '../repositories/client_create_order_repository_interface.dart';
/// Day-of-week labels in Sunday-first order, matching the V2 API convention.
const List<String> _dayLabels = <String>[
'SUN',
'MON',
'TUE',
'WED',
'THU',
'FRI',
'SAT',
];
/// Use case for creating a recurring staffing order. /// Use case for creating a recurring staffing order.
/// ///
/// Delegates the V2 API payload to the repository. /// Builds the V2 API payload from typed [RecurringOrderArguments] and
class CreateRecurringOrderUseCase { /// 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<RecurringOrderArguments, void> {
/// Creates a [CreateRecurringOrderUseCase]. /// Creates a [CreateRecurringOrderUseCase].
const CreateRecurringOrderUseCase(this._repository); const CreateRecurringOrderUseCase(this._repository);
/// The create-order repository.
final ClientCreateOrderRepositoryInterface _repository; final ClientCreateOrderRepositoryInterface _repository;
/// Executes the use case with the given [args]. @override
Future<void> call(RecurringOrderArguments args) { Future<void> call(RecurringOrderArguments input) {
return _repository.createRecurringOrder(args.payload); final String startDate = formatDateToIso(input.startDate);
final String endDate = formatDateToIso(input.endDate);
final List<int> recurrenceDays = input.recurringDays
.map((String day) => _dayLabels.indexOf(day) % 7)
.toList();
final List<Map<String, dynamic>> positions =
input.positions.map((RecurringOrderPositionArgument p) {
return <String, dynamic>{
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<String, dynamic> payload = <String, dynamic>{
'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);
} }
} }

View File

@@ -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<List<OrderHub>> {
/// Creates a [GetHubsUseCase].
const GetHubsUseCase(this._repository);
/// The query repository for order reference data.
final ClientOrderQueryRepositoryInterface _repository;
@override
Future<List<OrderHub>> call() {
return _repository.getHubs();
}
}

Some files were not shown because too many files have changed in this diff Show More