Merge dev into feature branch
This commit is contained in:
@@ -18,12 +18,56 @@
|
||||
- `firebase_data_connect` and `firebase_auth` are listed as direct dependencies in `client_create_order/pubspec.yaml` (should only be in `data_connect` package)
|
||||
- All 3 order pages use `Modular.to.pop()` instead of `Modular.to.popSafe()` for the back button
|
||||
|
||||
## Known Staff App Issues (full scan 2026-03-19)
|
||||
- [recurring_violations.md](recurring_violations.md) - Detailed violation patterns
|
||||
|
||||
### Critical
|
||||
- ProfileCubit calls repository directly (no use cases, no interface)
|
||||
- BenefitsOverviewCubit calls repository.getDashboard() directly (bypasses use case)
|
||||
- StaffMainCubit missing BlocErrorHandler mixin
|
||||
- firebase_auth imported directly in auth feature repos (2 files)
|
||||
|
||||
### High (Widespread)
|
||||
- 53 instances of `context.read<>()` without `ReadContext()` wrapper
|
||||
- ~20 hardcoded Color(0x...) values in home/benefits widgets
|
||||
- 5 custom TextStyle() in faqs_widget and tax_forms
|
||||
- 8 copyWith(fontSize:) overrides on UiTypography
|
||||
- ~40 hardcoded SizedBox spacing values
|
||||
- Hardcoded nav labels in staff_nav_items_config.dart
|
||||
- Zero test files across entire staff feature tree
|
||||
|
||||
## Design System Tokens
|
||||
- Colors: `UiColors.*`
|
||||
- Typography: `UiTypography.*`
|
||||
- Spacing: `UiConstants.space*` (e.g., `space3`, `space4`, `space6`)
|
||||
- App bar: `UiAppBar`
|
||||
|
||||
## Known Client App Issues (full scan 2026-03-19)
|
||||
|
||||
### Critical
|
||||
- Reports feature: All 7 report BLoCs call ReportsRepository directly (no use cases)
|
||||
- OneTimeOrderBloc, PermanentOrderBloc, RecurringOrderBloc call _queryRepository directly for loading vendors/hubs/roles
|
||||
- OneTimeOrderBloc._onSubmitted has payload building business logic (should be in use case)
|
||||
- ClientMainCubit missing BlocErrorHandler mixin
|
||||
- firebase_auth imported directly in authentication and settings feature repos (2 packages)
|
||||
|
||||
### High (Widespread)
|
||||
- 17 hardcoded Color(0x...) across reports, coverage, billing, hubs
|
||||
- 11 Material Colors.* usage (coverage, billing, reports)
|
||||
- 66 standalone TextStyle() (almost all in reports feature)
|
||||
- ~145 hardcoded EdgeInsets spacing values
|
||||
- ~97 hardcoded SizedBox dimensions
|
||||
- ~42 hardcoded BorderRadius.circular values
|
||||
- 6 unsafe Modular.to.pop() calls (settings, hubs)
|
||||
- BlocProvider(create:) used in no_show_report_page for Modular.get singleton
|
||||
- Zero test files across entire client feature tree
|
||||
- 2 hardcoded user-facing strings ("Export coming soon")
|
||||
- 9 files with blanket ignore_for_file directives (reports feature)
|
||||
|
||||
### Naming Convention Violations
|
||||
- CoverageRepository, BillingRepository, ReportsRepository missing "Interface" suffix
|
||||
- IViewOrdersRepository uses "I" prefix instead of "Interface" suffix
|
||||
|
||||
## Review Patterns (grep-based checks)
|
||||
- `Color(0x` for hardcoded colors
|
||||
- `TextStyle(` for custom text styles
|
||||
|
||||
3
.claude/agent-memory/mobile-feature-builder/MEMORY.md
Normal file
3
.claude/agent-memory/mobile-feature-builder/MEMORY.md
Normal 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
|
||||
@@ -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`.
|
||||
7
.claude/agent-memory/ui-ux-design/MEMORY.md
Normal file
7
.claude/agent-memory/ui-ux-design/MEMORY.md
Normal 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
|
||||
87
.claude/agent-memory/ui-ux-design/component-patterns.md
Normal file
87
.claude/agent-memory/ui-ux-design/component-patterns.md
Normal 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.
|
||||
22
.claude/agent-memory/ui-ux-design/design-gaps.md
Normal file
22
.claude/agent-memory/ui-ux-design/design-gaps.md
Normal 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.
|
||||
102
.claude/agent-memory/ui-ux-design/design-system-tokens.md
Normal file
102
.claude/agent-memory/ui-ux-design/design-system-tokens.md
Normal 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.
|
||||
247
.claude/agents/bug-reporter.md
Normal file
247
.claude/agents/bug-reporter.md
Normal 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.
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
@@ -84,7 +85,7 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
if (!_isInitialState) {
|
||||
debugPrint('[SessionListener] Session error: ${state.errorMessage}');
|
||||
_showSessionErrorDialog(
|
||||
state.errorMessage ?? 'Session error occurred',
|
||||
state.errorMessage ?? t.session.error_title,
|
||||
);
|
||||
} else {
|
||||
_isInitialState = false;
|
||||
@@ -101,22 +102,21 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
|
||||
/// Shows a dialog when the session expires.
|
||||
void _showSessionExpiredDialog() {
|
||||
final Translations translations = t;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('Session Expired'),
|
||||
content: const Text(
|
||||
'Your session has expired. Please log in again to continue.',
|
||||
),
|
||||
title: Text(translations.session.expired_title),
|
||||
content: Text(translations.session.expired_message),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Modular.to.popSafe();
|
||||
Navigator.of(dialogContext).pop();
|
||||
_proceedToLogin();
|
||||
},
|
||||
child: const Text('Log In'),
|
||||
child: Text(translations.session.log_in),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -126,27 +126,28 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
|
||||
/// Shows a dialog when a session error occurs, with retry option.
|
||||
void _showSessionErrorDialog(String errorMessage) {
|
||||
final Translations translations = t;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('Session Error'),
|
||||
title: Text(translations.session.error_title),
|
||||
content: Text(errorMessage),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// User can retry by dismissing and continuing
|
||||
Modular.to.popSafe();
|
||||
Navigator.of(dialogContext).pop();
|
||||
},
|
||||
child: const Text('Continue'),
|
||||
child: Text(translations.common.continue_text),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Modular.to.popSafe();
|
||||
Navigator.of(dialogContext).pop();
|
||||
_proceedToLogin();
|
||||
},
|
||||
child: const Text('Log Out'),
|
||||
child: Text(translations.session.log_out),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -34,7 +34,8 @@ dependencies:
|
||||
path: ../../packages/features/client/orders/create_order
|
||||
krow_core:
|
||||
path: ../../packages/core
|
||||
|
||||
krow_domain:
|
||||
path: ../../packages/domain
|
||||
cupertino_icons: ^1.0.8
|
||||
flutter_modular: ^6.3.2
|
||||
flutter_bloc: ^8.1.3
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
@@ -97,7 +98,7 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
if (!_isInitialState) {
|
||||
debugPrint('[SessionListener] Session error: ${state.errorMessage}');
|
||||
_showSessionErrorDialog(
|
||||
state.errorMessage ?? 'Session error occurred',
|
||||
state.errorMessage ?? t.session.error_title,
|
||||
);
|
||||
} else {
|
||||
_isInitialState = false;
|
||||
@@ -114,22 +115,21 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
|
||||
/// Shows a dialog when the session expires.
|
||||
void _showSessionExpiredDialog() {
|
||||
final Translations translations = t;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('Session Expired'),
|
||||
content: const Text(
|
||||
'Your session has expired. Please log in again to continue.',
|
||||
),
|
||||
title: Text(translations.session.expired_title),
|
||||
content: Text(translations.session.expired_message),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Modular.to.popSafe();
|
||||
Navigator.of(dialogContext).pop();
|
||||
_proceedToLogin();
|
||||
},
|
||||
child: const Text('Log In'),
|
||||
child: Text(translations.session.log_in),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -139,27 +139,28 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
|
||||
/// Shows a dialog when a session error occurs, with retry option.
|
||||
void _showSessionErrorDialog(String errorMessage) {
|
||||
final Translations translations = t;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('Session Error'),
|
||||
title: Text(translations.session.error_title),
|
||||
content: Text(errorMessage),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// User can retry by dismissing and continuing
|
||||
Modular.to.popSafe();
|
||||
Navigator.of(dialogContext).pop();
|
||||
},
|
||||
child: const Text('Continue'),
|
||||
child: Text(translations.common.continue_text),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Modular.to.popSafe();
|
||||
Navigator.of(dialogContext).pop();
|
||||
_proceedToLogin();
|
||||
},
|
||||
child: const Text('Log Out'),
|
||||
child: Text(translations.session.log_out),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -28,6 +28,8 @@ dependencies:
|
||||
path: ../../packages/features/staff/staff_main
|
||||
krow_core:
|
||||
path: ../../packages/core
|
||||
krow_domain:
|
||||
path: ../../packages/domain
|
||||
cupertino_icons: ^1.0.8
|
||||
flutter_modular: ^6.3.0
|
||||
firebase_core: ^4.4.0
|
||||
|
||||
@@ -42,6 +42,10 @@ export 'src/services/session/client_session_store.dart';
|
||||
export 'src/services/session/staff_session_store.dart';
|
||||
export 'src/services/session/v2_session_service.dart';
|
||||
|
||||
// Auth
|
||||
export 'src/services/auth/auth_token_provider.dart';
|
||||
export 'src/services/auth/firebase_auth_service.dart';
|
||||
|
||||
// Device Services
|
||||
export 'src/services/device/camera/camera_service.dart';
|
||||
export 'src/services/device/gallery/gallery_service.dart';
|
||||
|
||||
@@ -3,6 +3,10 @@ import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:krow_core/src/services/auth/auth_token_provider.dart';
|
||||
import 'package:krow_core/src/services/auth/firebase_auth_service.dart';
|
||||
import 'package:krow_core/src/services/auth/firebase_auth_token_provider.dart';
|
||||
|
||||
import '../core.dart';
|
||||
|
||||
/// A module that provides core services and shared dependencies.
|
||||
@@ -57,7 +61,13 @@ class CoreModule extends Module {
|
||||
),
|
||||
);
|
||||
|
||||
// 6. Register Geofence Device Services
|
||||
// 6. Auth Token Provider
|
||||
i.addLazySingleton<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<NotificationService>(() => NotificationService());
|
||||
i.addLazySingleton<StorageService>(() => StorageService());
|
||||
|
||||
@@ -60,6 +60,20 @@ extension StaffNavigator on IModularNavigator {
|
||||
safePush(StaffPaths.benefits);
|
||||
}
|
||||
|
||||
/// Navigates to the full history page for a specific benefit.
|
||||
void toBenefitHistory({
|
||||
required String benefitId,
|
||||
required String benefitTitle,
|
||||
}) {
|
||||
safePush(
|
||||
StaffPaths.benefitHistory,
|
||||
arguments: <String, dynamic>{
|
||||
'benefitId': benefitId,
|
||||
'benefitTitle': benefitTitle,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void toStaffMain() {
|
||||
safePushNamedAndRemoveUntil('${StaffPaths.main}/home/', (_) => false);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,9 @@ class StaffPaths {
|
||||
/// Benefits overview page.
|
||||
static const String benefits = '/worker-main/home/benefits';
|
||||
|
||||
/// Benefit history page for a specific benefit.
|
||||
static const String benefitHistory = '/worker-main/home/benefits/history';
|
||||
|
||||
/// Shifts tab - view and manage shifts.
|
||||
///
|
||||
/// Browse available shifts, accepted shifts, and shift history.
|
||||
|
||||
@@ -48,6 +48,26 @@ abstract final class ClientEndpoints {
|
||||
static const ApiEndpoint coverageCoreTeam =
|
||||
ApiEndpoint('/client/coverage/core-team');
|
||||
|
||||
/// Coverage incidents.
|
||||
static const ApiEndpoint coverageIncidents =
|
||||
ApiEndpoint('/client/coverage/incidents');
|
||||
|
||||
/// Blocked staff.
|
||||
static const ApiEndpoint coverageBlockedStaff =
|
||||
ApiEndpoint('/client/coverage/blocked-staff');
|
||||
|
||||
/// Coverage swap requests.
|
||||
static const ApiEndpoint coverageSwapRequests =
|
||||
ApiEndpoint('/client/coverage/swap-requests');
|
||||
|
||||
/// Dispatch teams.
|
||||
static const ApiEndpoint coverageDispatchTeams =
|
||||
ApiEndpoint('/client/coverage/dispatch-teams');
|
||||
|
||||
/// Dispatch candidates.
|
||||
static const ApiEndpoint coverageDispatchCandidates =
|
||||
ApiEndpoint('/client/coverage/dispatch-candidates');
|
||||
|
||||
/// Hubs list.
|
||||
static const ApiEndpoint hubs = ApiEndpoint('/client/hubs');
|
||||
|
||||
@@ -162,4 +182,28 @@ abstract final class ClientEndpoints {
|
||||
/// Cancel late worker assignment.
|
||||
static ApiEndpoint coverageCancelLateWorker(String assignmentId) =>
|
||||
ApiEndpoint('/client/coverage/late-workers/$assignmentId/cancel');
|
||||
|
||||
/// Register or delete device push token (POST to register, DELETE to remove).
|
||||
static const ApiEndpoint devicesPushTokens =
|
||||
ApiEndpoint('/client/devices/push-tokens');
|
||||
|
||||
/// Create shift manager.
|
||||
static const ApiEndpoint shiftManagerCreate =
|
||||
ApiEndpoint('/client/shift-managers');
|
||||
|
||||
/// Resolve coverage swap request by ID.
|
||||
static ApiEndpoint coverageSwapRequestResolve(String id) =>
|
||||
ApiEndpoint('/client/coverage/swap-requests/$id/resolve');
|
||||
|
||||
/// Cancel coverage swap request by ID.
|
||||
static ApiEndpoint coverageSwapRequestCancel(String id) =>
|
||||
ApiEndpoint('/client/coverage/swap-requests/$id/cancel');
|
||||
|
||||
/// Create dispatch team membership.
|
||||
static const ApiEndpoint coverageDispatchTeamMembershipsCreate =
|
||||
ApiEndpoint('/client/coverage/dispatch-teams/memberships');
|
||||
|
||||
/// Delete dispatch team membership by ID.
|
||||
static ApiEndpoint coverageDispatchTeamMembershipsDelete(String id) =>
|
||||
ApiEndpoint('/client/coverage/dispatch-teams/memberships/$id');
|
||||
}
|
||||
|
||||
@@ -2,39 +2,42 @@ import 'package:krow_domain/krow_domain.dart' show ApiEndpoint;
|
||||
|
||||
/// Core infrastructure endpoints (upload, signed URLs, LLM, verifications,
|
||||
/// rapid orders).
|
||||
///
|
||||
/// Paths are at the unified API root level (not under `/core/`).
|
||||
abstract final class CoreEndpoints {
|
||||
/// Upload a file.
|
||||
static const ApiEndpoint uploadFile =
|
||||
ApiEndpoint('/core/upload-file');
|
||||
static const ApiEndpoint uploadFile = ApiEndpoint('/upload-file');
|
||||
|
||||
/// Create a signed URL for a file.
|
||||
static const ApiEndpoint createSignedUrl =
|
||||
ApiEndpoint('/core/create-signed-url');
|
||||
static const ApiEndpoint createSignedUrl = ApiEndpoint('/create-signed-url');
|
||||
|
||||
/// Invoke a Large Language Model.
|
||||
static const ApiEndpoint invokeLlm = ApiEndpoint('/core/invoke-llm');
|
||||
static const ApiEndpoint invokeLlm = ApiEndpoint('/invoke-llm');
|
||||
|
||||
/// Root for verification operations.
|
||||
static const ApiEndpoint verifications =
|
||||
ApiEndpoint('/core/verifications');
|
||||
static const ApiEndpoint verifications = ApiEndpoint('/verifications');
|
||||
|
||||
/// Get status of a verification job.
|
||||
static ApiEndpoint verificationStatus(String id) =>
|
||||
ApiEndpoint('/core/verifications/$id');
|
||||
ApiEndpoint('/verifications/$id');
|
||||
|
||||
/// Review a verification decision.
|
||||
static ApiEndpoint verificationReview(String id) =>
|
||||
ApiEndpoint('/core/verifications/$id/review');
|
||||
ApiEndpoint('/verifications/$id/review');
|
||||
|
||||
/// Retry a verification job.
|
||||
static ApiEndpoint verificationRetry(String id) =>
|
||||
ApiEndpoint('/core/verifications/$id/retry');
|
||||
ApiEndpoint('/verifications/$id/retry');
|
||||
|
||||
/// Transcribe audio to text for rapid orders.
|
||||
static const ApiEndpoint transcribeRapidOrder =
|
||||
ApiEndpoint('/core/rapid-orders/transcribe');
|
||||
ApiEndpoint('/rapid-orders/transcribe');
|
||||
|
||||
/// Parse text to structured rapid order.
|
||||
static const ApiEndpoint parseRapidOrder =
|
||||
ApiEndpoint('/core/rapid-orders/parse');
|
||||
ApiEndpoint('/rapid-orders/parse');
|
||||
|
||||
/// Combined transcribe + parse in a single call.
|
||||
static const ApiEndpoint processRapidOrder =
|
||||
ApiEndpoint('/rapid-orders/process');
|
||||
}
|
||||
|
||||
@@ -105,6 +105,10 @@ abstract final class StaffEndpoints {
|
||||
/// Benefits.
|
||||
static const ApiEndpoint benefits = ApiEndpoint('/staff/profile/benefits');
|
||||
|
||||
/// Benefits history.
|
||||
static const ApiEndpoint benefitsHistory =
|
||||
ApiEndpoint('/staff/profile/benefits/history');
|
||||
|
||||
/// Time card.
|
||||
static const ApiEndpoint timeCard =
|
||||
ApiEndpoint('/staff/profile/time-card');
|
||||
@@ -112,6 +116,10 @@ abstract final class StaffEndpoints {
|
||||
/// Privacy settings.
|
||||
static const ApiEndpoint privacy = ApiEndpoint('/staff/profile/privacy');
|
||||
|
||||
/// Preferred locations.
|
||||
static const ApiEndpoint locations =
|
||||
ApiEndpoint('/staff/profile/locations');
|
||||
|
||||
/// FAQs.
|
||||
static const ApiEndpoint faqs = ApiEndpoint('/staff/faqs');
|
||||
|
||||
@@ -177,4 +185,16 @@ abstract final class StaffEndpoints {
|
||||
/// Delete certificate by ID.
|
||||
static ApiEndpoint certificateDelete(String certificateId) =>
|
||||
ApiEndpoint('/staff/profile/certificates/$certificateId');
|
||||
|
||||
/// Submit shift for approval.
|
||||
static ApiEndpoint shiftSubmitForApproval(String shiftId) =>
|
||||
ApiEndpoint('/staff/shifts/$shiftId/submit-for-approval');
|
||||
|
||||
/// Location streams.
|
||||
static const ApiEndpoint locationStreams =
|
||||
ApiEndpoint('/staff/location-streams');
|
||||
|
||||
/// Register or delete device push token (POST to register, DELETE to remove).
|
||||
static const ApiEndpoint devicesPushTokens =
|
||||
ApiEndpoint('/staff/devices/push-tokens');
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ mixin ApiErrorHandler {
|
||||
);
|
||||
|
||||
case DioExceptionType.cancel:
|
||||
return UnknownException(
|
||||
return const UnknownException(
|
||||
technicalMessage: 'Request cancelled',
|
||||
);
|
||||
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,45 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Converts a break duration label (e.g. `'MIN_30'`) to its value in minutes.
|
||||
///
|
||||
/// Recognised labels: `MIN_10`, `MIN_15`, `MIN_30`, `MIN_45`, `MIN_60`.
|
||||
/// Returns `0` for any unrecognised value (including `'NO_BREAK'`).
|
||||
int breakMinutesFromLabel(String label) {
|
||||
switch (label) {
|
||||
case 'MIN_10':
|
||||
return 10;
|
||||
case 'MIN_15':
|
||||
return 15;
|
||||
case 'MIN_30':
|
||||
return 30;
|
||||
case 'MIN_45':
|
||||
return 45;
|
||||
case 'MIN_60':
|
||||
return 60;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a [DateTime] to a `yyyy-MM-dd` date string.
|
||||
///
|
||||
/// Example: `DateTime(2026, 3, 5)` -> `'2026-03-05'`.
|
||||
String formatDateToIso(DateTime date) {
|
||||
return '${date.year.toString().padLeft(4, '0')}-'
|
||||
'${date.month.toString().padLeft(2, '0')}-'
|
||||
'${date.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// Formats a [DateTime] to `HH:mm` (24-hour) time string.
|
||||
///
|
||||
/// Converts to local time before formatting.
|
||||
/// Example: a UTC DateTime of 14:30 in UTC-5 -> `'09:30'`.
|
||||
String formatTimeHHmm(DateTime dt) {
|
||||
final DateTime local = dt.toLocal();
|
||||
return '${local.hour.toString().padLeft(2, '0')}:'
|
||||
'${local.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// Formats a time string (ISO 8601 or HH:mm) into 12-hour format
|
||||
/// (e.g. "9:00 AM").
|
||||
///
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
"english": "English",
|
||||
"spanish": "Español"
|
||||
},
|
||||
"session": {
|
||||
"expired_title": "Session Expired",
|
||||
"expired_message": "Your session has expired. Please log in again to continue.",
|
||||
"error_title": "Session Error",
|
||||
"log_in": "Log In",
|
||||
"log_out": "Log Out"
|
||||
},
|
||||
"settings": {
|
||||
"language": "Language",
|
||||
"change_language": "Change Language"
|
||||
@@ -665,7 +672,14 @@
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"submitted": "Submitted"
|
||||
}
|
||||
},
|
||||
"history_header": "HISTORY",
|
||||
"no_history": "No history yet",
|
||||
"show_all": "Show all",
|
||||
"hours_accrued": "+${hours}h accrued",
|
||||
"hours_used": "-${hours}h used",
|
||||
"history_page_title": "$benefit History",
|
||||
"loading_more": "Loading..."
|
||||
}
|
||||
},
|
||||
"auto_match": {
|
||||
@@ -964,11 +978,15 @@
|
||||
"retry": "Retry",
|
||||
"clock_in_anyway": "Clock In Anyway",
|
||||
"override_title": "Justification Required",
|
||||
"override_desc": "Your location could not be verified. Please explain why you are clocking in without location verification.",
|
||||
"override_desc": "Your location could not be verified. Please explain why you are proceeding without location verification.",
|
||||
"override_hint": "Enter your justification...",
|
||||
"override_submit": "Clock In",
|
||||
"override_submit": "Submit",
|
||||
"overridden_title": "Location Not Verified",
|
||||
"overridden_desc": "You are clocking in without location verification. Your justification has been recorded."
|
||||
"overridden_desc": "You are proceeding without location verification. Your justification has been recorded.",
|
||||
"outside_work_area_warning": "You've moved away from the work area",
|
||||
"outside_work_area_title": "You've moved away from the work area",
|
||||
"outside_work_area_desc": "You are $distance away from your shift location. To clock out, provide a reason below.",
|
||||
"clock_out_anyway": "Clock out anyway"
|
||||
}
|
||||
},
|
||||
"availability": {
|
||||
@@ -1159,6 +1177,8 @@
|
||||
"upload": {
|
||||
"instructions": "Please select a valid PDF file to upload.",
|
||||
"pdf_banner": "Only PDF files are accepted. Maximum file size is 10MB.",
|
||||
"pdf_banner_title": "PDF files only",
|
||||
"pdf_banner_description": "Upload a PDF document up to 10MB in size.",
|
||||
"file_not_found": "File not found.",
|
||||
"submit": "Submit Document",
|
||||
"select_pdf": "Select PDF File",
|
||||
@@ -1337,14 +1357,22 @@
|
||||
"applying_dialog": {
|
||||
"title": "Applying"
|
||||
},
|
||||
"eligibility_requirements": "Eligibility Requirements"
|
||||
"eligibility_requirements": "Eligibility Requirements",
|
||||
"missing_certifications": "You are missing required certifications or documents to claim this shift. Please upload them to continue.",
|
||||
"go_to_certificates": "Go to Certificates",
|
||||
"shift_booked": "Shift successfully booked!",
|
||||
"shift_not_found": "Shift not found",
|
||||
"shift_declined_success": "Shift declined",
|
||||
"complete_account_title": "Complete Your Account",
|
||||
"complete_account_description": "Complete your account to book this shift and start earning"
|
||||
},
|
||||
"my_shift_card": {
|
||||
"submit_for_approval": "Submit for Approval",
|
||||
"timesheet_submitted": "Timesheet submitted for client approval",
|
||||
"checked_in": "Checked in",
|
||||
"submitted": "SUBMITTED",
|
||||
"ready_to_submit": "READY TO SUBMIT"
|
||||
"ready_to_submit": "READY TO SUBMIT",
|
||||
"submitting": "SUBMITTING..."
|
||||
},
|
||||
"shift_location": {
|
||||
"could_not_open_maps": "Could not open maps"
|
||||
@@ -1457,11 +1485,14 @@
|
||||
"shift": {
|
||||
"no_open_roles": "There are no open positions available for this shift.",
|
||||
"application_not_found": "Your application couldn't be found.",
|
||||
"no_active_shift": "You don't have an active shift to clock out from."
|
||||
"no_active_shift": "You don't have an active shift to clock out from.",
|
||||
"not_found": "Shift not found. It may have been removed or is no longer available."
|
||||
},
|
||||
"clock_in": {
|
||||
"location_verification_required": "Please wait for location verification before clocking in.",
|
||||
"notes_required_for_timeout": "Please add a note explaining why your location can't be verified."
|
||||
"notes_required_for_timeout": "Please add a note explaining why your location can't be verified.",
|
||||
"already_clocked_in": "You're already clocked in to this shift.",
|
||||
"already_clocked_out": "You've already clocked out of this shift."
|
||||
},
|
||||
"generic": {
|
||||
"unknown": "Something went wrong. Please try again.",
|
||||
@@ -1762,7 +1793,9 @@
|
||||
"workers": "Workers",
|
||||
"error_occurred": "An error occurred",
|
||||
"retry": "Retry",
|
||||
"shifts": "Shifts"
|
||||
"shifts": "Shifts",
|
||||
"overall_coverage": "Overall Coverage",
|
||||
"live_activity": "LIVE ACTIVITY"
|
||||
},
|
||||
"calendar": {
|
||||
"prev_week": "\u2190 Prev Week",
|
||||
@@ -1771,7 +1804,9 @@
|
||||
},
|
||||
"stats": {
|
||||
"checked_in": "Checked In",
|
||||
"en_route": "En Route"
|
||||
"en_route": "En Route",
|
||||
"on_site": "On Site",
|
||||
"late": "Late"
|
||||
},
|
||||
"alert": {
|
||||
"workers_running_late(count)": {
|
||||
@@ -1779,6 +1814,45 @@
|
||||
"other": "$count workers are running late"
|
||||
},
|
||||
"auto_backup_searching": "Auto-backup system is searching for replacements."
|
||||
},
|
||||
"review": {
|
||||
"title": "Rate this worker",
|
||||
"subtitle": "Share your feedback",
|
||||
"rating_labels": {
|
||||
"poor": "Poor",
|
||||
"fair": "Fair",
|
||||
"good": "Good",
|
||||
"great": "Great",
|
||||
"excellent": "Excellent"
|
||||
},
|
||||
"favorite_label": "Favorite",
|
||||
"block_label": "Block",
|
||||
"feedback_placeholder": "Share details about this worker's performance...",
|
||||
"submit": "Submit Review",
|
||||
"success": "Review submitted successfully",
|
||||
"issue_flags": {
|
||||
"late": "Late",
|
||||
"uniform": "Uniform",
|
||||
"misconduct": "Misconduct",
|
||||
"no_show": "No Show",
|
||||
"attitude": "Attitude",
|
||||
"performance": "Performance",
|
||||
"left_early": "Left Early"
|
||||
}
|
||||
},
|
||||
"cancel": {
|
||||
"title": "Cancel Worker?",
|
||||
"subtitle": "This cannot be undone",
|
||||
"confirm_message": "Are you sure you want to cancel $name?",
|
||||
"helper_text": "They will receive a cancellation notification. A replacement will be automatically requested.",
|
||||
"reason_placeholder": "Reason for cancellation (optional)",
|
||||
"keep_worker": "Keep Worker",
|
||||
"confirm": "Yes, Cancel",
|
||||
"success": "Worker cancelled. Searching for replacement."
|
||||
},
|
||||
"actions": {
|
||||
"rate": "Rate",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
},
|
||||
"client_reports_common": {
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
"english": "English",
|
||||
"spanish": "Español"
|
||||
},
|
||||
"session": {
|
||||
"expired_title": "Sesión Expirada",
|
||||
"expired_message": "Tu sesión ha expirado. Por favor inicia sesión de nuevo para continuar.",
|
||||
"error_title": "Error de Sesión",
|
||||
"log_in": "Iniciar Sesión",
|
||||
"log_out": "Cerrar Sesión"
|
||||
},
|
||||
"settings": {
|
||||
"language": "Idioma",
|
||||
"change_language": "Cambiar Idioma"
|
||||
@@ -660,7 +667,14 @@
|
||||
"status": {
|
||||
"pending": "Pendiente",
|
||||
"submitted": "Enviado"
|
||||
}
|
||||
},
|
||||
"history_header": "HISTORIAL",
|
||||
"no_history": "Sin historial aún",
|
||||
"show_all": "Ver todo",
|
||||
"hours_accrued": "+${hours}h acumuladas",
|
||||
"hours_used": "-${hours}h utilizadas",
|
||||
"history_page_title": "Historial de $benefit",
|
||||
"loading_more": "Cargando..."
|
||||
}
|
||||
},
|
||||
"auto_match": {
|
||||
@@ -959,11 +973,15 @@
|
||||
"retry": "Reintentar",
|
||||
"clock_in_anyway": "Registrar Entrada",
|
||||
"override_title": "Justificación Requerida",
|
||||
"override_desc": "No se pudo verificar su ubicación. Explique por qué registra entrada sin verificación de ubicación.",
|
||||
"override_desc": "No se pudo verificar su ubicación. Explique por qué continúa sin verificación de ubicación.",
|
||||
"override_hint": "Ingrese su justificación...",
|
||||
"override_submit": "Registrar Entrada",
|
||||
"override_submit": "Enviar",
|
||||
"overridden_title": "Ubicación No Verificada",
|
||||
"overridden_desc": "Está registrando entrada sin verificación de ubicación. Su justificación ha sido registrada."
|
||||
"overridden_desc": "Está continuando sin verificación de ubicación. Su justificación ha sido registrada.",
|
||||
"outside_work_area_warning": "Te has alejado del área de trabajo",
|
||||
"outside_work_area_title": "Te has alejado del área de trabajo",
|
||||
"outside_work_area_desc": "Estás a $distance de la ubicación de tu turno. Para registrar tu salida, proporciona una razón a continuación.",
|
||||
"clock_out_anyway": "Registrar salida de todos modos"
|
||||
}
|
||||
},
|
||||
"availability": {
|
||||
@@ -1154,6 +1172,8 @@
|
||||
"upload": {
|
||||
"instructions": "Por favor selecciona un archivo PDF válido para subir.",
|
||||
"pdf_banner": "Solo se aceptan archivos PDF. Tamaño máximo del archivo: 10MB.",
|
||||
"pdf_banner_title": "Solo archivos PDF",
|
||||
"pdf_banner_description": "Sube un documento PDF de hasta 10MB de tamaño.",
|
||||
"submit": "Enviar Documento",
|
||||
"select_pdf": "Seleccionar Archivo PDF",
|
||||
"attestation": "Certifico que este documento es genuino y válido.",
|
||||
@@ -1332,14 +1352,22 @@
|
||||
"applying_dialog": {
|
||||
"title": "Solicitando"
|
||||
},
|
||||
"eligibility_requirements": "Requisitos de Elegibilidad"
|
||||
"eligibility_requirements": "Requisitos de Elegibilidad",
|
||||
"missing_certifications": "Te faltan certificaciones o documentos requeridos para reclamar este turno. Por favor, súbelos para continuar.",
|
||||
"go_to_certificates": "Ir a Certificados",
|
||||
"shift_booked": "¡Turno reservado con éxito!",
|
||||
"shift_not_found": "Turno no encontrado",
|
||||
"shift_declined_success": "Turno rechazado",
|
||||
"complete_account_title": "Completa Tu Cuenta",
|
||||
"complete_account_description": "Completa tu cuenta para reservar este turno y comenzar a ganar"
|
||||
},
|
||||
"my_shift_card": {
|
||||
"submit_for_approval": "Enviar para Aprobación",
|
||||
"timesheet_submitted": "Hoja de tiempo enviada para aprobación del cliente",
|
||||
"checked_in": "Registrado",
|
||||
"submitted": "ENVIADO",
|
||||
"ready_to_submit": "LISTO PARA ENVIAR"
|
||||
"ready_to_submit": "LISTO PARA ENVIAR",
|
||||
"submitting": "ENVIANDO..."
|
||||
},
|
||||
"shift_location": {
|
||||
"could_not_open_maps": "No se pudo abrir mapas"
|
||||
@@ -1452,11 +1480,14 @@
|
||||
"shift": {
|
||||
"no_open_roles": "No hay posiciones abiertas disponibles para este turno.",
|
||||
"application_not_found": "No se pudo encontrar tu solicitud.",
|
||||
"no_active_shift": "No tienes un turno activo para registrar salida."
|
||||
"no_active_shift": "No tienes un turno activo para registrar salida.",
|
||||
"not_found": "Turno no encontrado. Puede haber sido eliminado o ya no está disponible."
|
||||
},
|
||||
"clock_in": {
|
||||
"location_verification_required": "Por favor, espera la verificaci\u00f3n de ubicaci\u00f3n antes de registrar entrada.",
|
||||
"notes_required_for_timeout": "Por favor, agrega una nota explicando por qu\u00e9 no se puede verificar tu ubicaci\u00f3n."
|
||||
"notes_required_for_timeout": "Por favor, agrega una nota explicando por qu\u00e9 no se puede verificar tu ubicaci\u00f3n.",
|
||||
"already_clocked_in": "Ya est\u00e1s registrado en este turno.",
|
||||
"already_clocked_out": "Ya registraste tu salida de este turno."
|
||||
},
|
||||
"generic": {
|
||||
"unknown": "Algo sali\u00f3 mal. Por favor, intenta de nuevo.",
|
||||
@@ -1762,7 +1793,9 @@
|
||||
"workers": "Trabajadores",
|
||||
"error_occurred": "Ocurri\u00f3 un error",
|
||||
"retry": "Reintentar",
|
||||
"shifts": "Turnos"
|
||||
"shifts": "Turnos",
|
||||
"overall_coverage": "Cobertura General",
|
||||
"live_activity": "ACTIVIDAD EN VIVO"
|
||||
},
|
||||
"calendar": {
|
||||
"prev_week": "\u2190 Semana Anterior",
|
||||
@@ -1771,7 +1804,9 @@
|
||||
},
|
||||
"stats": {
|
||||
"checked_in": "Registrado",
|
||||
"en_route": "En Camino"
|
||||
"en_route": "En Camino",
|
||||
"on_site": "En Sitio",
|
||||
"late": "Tarde"
|
||||
},
|
||||
"alert": {
|
||||
"workers_running_late(count)": {
|
||||
@@ -1779,6 +1814,45 @@
|
||||
"other": "$count trabajadores est\u00e1n llegando tarde"
|
||||
},
|
||||
"auto_backup_searching": "El sistema de respaldo autom\u00e1tico est\u00e1 buscando reemplazos."
|
||||
},
|
||||
"review": {
|
||||
"title": "Calificar a este trabajador",
|
||||
"subtitle": "Comparte tu opini\u00f3n",
|
||||
"rating_labels": {
|
||||
"poor": "Malo",
|
||||
"fair": "Regular",
|
||||
"good": "Bueno",
|
||||
"great": "Muy Bueno",
|
||||
"excellent": "Excelente"
|
||||
},
|
||||
"favorite_label": "Favorito",
|
||||
"block_label": "Bloquear",
|
||||
"feedback_placeholder": "Comparte detalles sobre el desempe\u00f1o de este trabajador...",
|
||||
"submit": "Enviar Rese\u00f1a",
|
||||
"success": "Rese\u00f1a enviada exitosamente",
|
||||
"issue_flags": {
|
||||
"late": "Tarde",
|
||||
"uniform": "Uniforme",
|
||||
"misconduct": "Mala Conducta",
|
||||
"no_show": "No Se Present\u00f3",
|
||||
"attitude": "Actitud",
|
||||
"performance": "Rendimiento",
|
||||
"left_early": "Sali\u00f3 Temprano"
|
||||
}
|
||||
},
|
||||
"cancel": {
|
||||
"title": "\u00bfCancelar Trabajador?",
|
||||
"subtitle": "Esta acci\u00f3n no se puede deshacer",
|
||||
"confirm_message": "\u00bfEst\u00e1s seguro de que deseas cancelar a $name?",
|
||||
"helper_text": "Recibir\u00e1n una notificaci\u00f3n de cancelaci\u00f3n. Se solicitar\u00e1 un reemplazo autom\u00e1ticamente.",
|
||||
"reason_placeholder": "Raz\u00f3n de la cancelaci\u00f3n (opcional)",
|
||||
"keep_worker": "Mantener Trabajador",
|
||||
"confirm": "S\u00ed, Cancelar",
|
||||
"success": "Trabajador cancelado. Buscando reemplazo."
|
||||
},
|
||||
"actions": {
|
||||
"rate": "Calificar",
|
||||
"cancel": "Cancelar"
|
||||
}
|
||||
},
|
||||
"client_reports_common": {
|
||||
|
||||
@@ -124,6 +124,8 @@ String _translateShiftError(String errorType) {
|
||||
return t.errors.shift.application_not_found;
|
||||
case 'no_active_shift':
|
||||
return t.errors.shift.no_active_shift;
|
||||
case 'not_found':
|
||||
return t.errors.shift.not_found;
|
||||
default:
|
||||
return t.errors.generic.unknown;
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ class UiChip extends StatelessWidget {
|
||||
|
||||
final Row content = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
if (leadingIcon != null) ...<Widget>[
|
||||
Icon(leadingIcon, size: iconSize, color: contentColor),
|
||||
|
||||
@@ -84,6 +84,7 @@ class UiNoticeBanner extends StatelessWidget {
|
||||
style: UiTypography.body2b.copyWith(
|
||||
color: titleColor ?? UiColors.primary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -18,6 +18,7 @@ export 'src/entities/enums/invoice_status.dart';
|
||||
export 'src/entities/enums/onboarding_status.dart';
|
||||
export 'src/entities/enums/order_type.dart';
|
||||
export 'src/entities/enums/payment_status.dart';
|
||||
export 'src/entities/enums/review_issue_flag.dart';
|
||||
export 'src/entities/enums/shift_status.dart';
|
||||
export 'src/entities/enums/staff_industry.dart';
|
||||
export 'src/entities/enums/staff_skill.dart';
|
||||
@@ -72,6 +73,7 @@ export 'src/entities/orders/recent_order.dart';
|
||||
|
||||
// Financial & Payroll
|
||||
export 'src/entities/benefits/benefit.dart';
|
||||
export 'src/entities/benefits/benefit_history.dart';
|
||||
export 'src/entities/financial/invoice.dart';
|
||||
export 'src/entities/financial/billing_account.dart';
|
||||
export 'src/entities/financial/current_bill.dart';
|
||||
|
||||
@@ -2,6 +2,14 @@ import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Represents a geographic location obtained from the device.
|
||||
class DeviceLocation extends Equatable {
|
||||
|
||||
/// Creates a [DeviceLocation] instance.
|
||||
const DeviceLocation({
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
required this.accuracy,
|
||||
required this.timestamp,
|
||||
});
|
||||
/// Latitude in degrees.
|
||||
final double latitude;
|
||||
|
||||
@@ -14,14 +22,6 @@ class DeviceLocation extends Equatable {
|
||||
/// Time when this location was determined.
|
||||
final DateTime timestamp;
|
||||
|
||||
/// Creates a [DeviceLocation] instance.
|
||||
const DeviceLocation({
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
required this.accuracy,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [latitude, longitude, accuracy, timestamp];
|
||||
List<Object?> get props => <Object?>[latitude, longitude, accuracy, timestamp];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -18,6 +18,10 @@ class AssignedShift extends Equatable {
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.hourlyRateCents,
|
||||
required this.hourlyRate,
|
||||
required this.totalRateCents,
|
||||
required this.totalRate,
|
||||
required this.clientName,
|
||||
required this.orderType,
|
||||
required this.status,
|
||||
});
|
||||
@@ -33,6 +37,10 @@ class AssignedShift extends Equatable {
|
||||
startTime: DateTime.parse(json['startTime'] as String),
|
||||
endTime: DateTime.parse(json['endTime'] as String),
|
||||
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
||||
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
|
||||
totalRateCents: json['totalRateCents'] as int? ?? 0,
|
||||
totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0,
|
||||
clientName: json['clientName'] as String? ?? '',
|
||||
orderType: OrderType.fromJson(json['orderType'] as String?),
|
||||
status: AssignmentStatus.fromJson(json['status'] as String?),
|
||||
);
|
||||
@@ -62,6 +70,18 @@ class AssignedShift extends Equatable {
|
||||
/// Pay rate in cents per hour.
|
||||
final int hourlyRateCents;
|
||||
|
||||
/// Pay rate in dollars per hour.
|
||||
final double hourlyRate;
|
||||
|
||||
/// Total pay for this shift in cents.
|
||||
final int totalRateCents;
|
||||
|
||||
/// Total pay for this shift in dollars.
|
||||
final double totalRate;
|
||||
|
||||
/// Name of the client / business for this shift.
|
||||
final String clientName;
|
||||
|
||||
/// Order type.
|
||||
final OrderType orderType;
|
||||
|
||||
@@ -79,6 +99,10 @@ class AssignedShift extends Equatable {
|
||||
'startTime': startTime.toIso8601String(),
|
||||
'endTime': endTime.toIso8601String(),
|
||||
'hourlyRateCents': hourlyRateCents,
|
||||
'hourlyRate': hourlyRate,
|
||||
'totalRateCents': totalRateCents,
|
||||
'totalRate': totalRate,
|
||||
'clientName': clientName,
|
||||
'orderType': orderType.toJson(),
|
||||
'status': status.toJson(),
|
||||
};
|
||||
@@ -94,6 +118,10 @@ class AssignedShift extends Equatable {
|
||||
startTime,
|
||||
endTime,
|
||||
hourlyRateCents,
|
||||
hourlyRate,
|
||||
totalRateCents,
|
||||
totalRate,
|
||||
clientName,
|
||||
orderType,
|
||||
status,
|
||||
];
|
||||
|
||||
@@ -12,10 +12,18 @@ class CompletedShift extends Equatable {
|
||||
required this.shiftId,
|
||||
required this.title,
|
||||
required this.location,
|
||||
required this.clientName,
|
||||
required this.date,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.minutesWorked,
|
||||
required this.hourlyRateCents,
|
||||
required this.hourlyRate,
|
||||
required this.totalRateCents,
|
||||
required this.totalRate,
|
||||
required this.paymentStatus,
|
||||
required this.status,
|
||||
this.timesheetStatus,
|
||||
});
|
||||
|
||||
/// Deserialises from the V2 API JSON response.
|
||||
@@ -25,10 +33,22 @@ class CompletedShift extends Equatable {
|
||||
shiftId: json['shiftId'] as String,
|
||||
title: json['title'] as String? ?? '',
|
||||
location: json['location'] as String? ?? '',
|
||||
clientName: json['clientName'] as String? ?? '',
|
||||
date: DateTime.parse(json['date'] as String),
|
||||
startTime: json['startTime'] != null
|
||||
? DateTime.parse(json['startTime'] as String)
|
||||
: DateTime.now(),
|
||||
endTime: json['endTime'] != null
|
||||
? DateTime.parse(json['endTime'] as String)
|
||||
: DateTime.now(),
|
||||
minutesWorked: json['minutesWorked'] as int? ?? 0,
|
||||
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
||||
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
|
||||
totalRateCents: json['totalRateCents'] as int? ?? 0,
|
||||
totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0,
|
||||
paymentStatus: PaymentStatus.fromJson(json['paymentStatus'] as String?),
|
||||
status: AssignmentStatus.completed,
|
||||
timesheetStatus: json['timesheetStatus'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,18 +64,42 @@ class CompletedShift extends Equatable {
|
||||
/// Human-readable location label.
|
||||
final String location;
|
||||
|
||||
/// Name of the client / business for this shift.
|
||||
final String clientName;
|
||||
|
||||
/// The date the shift was worked.
|
||||
final DateTime date;
|
||||
|
||||
/// Scheduled start time.
|
||||
final DateTime startTime;
|
||||
|
||||
/// Scheduled end time.
|
||||
final DateTime endTime;
|
||||
|
||||
/// Total minutes worked (regular + overtime).
|
||||
final int minutesWorked;
|
||||
|
||||
/// Pay rate in cents per hour.
|
||||
final int hourlyRateCents;
|
||||
|
||||
/// Pay rate in dollars per hour.
|
||||
final double hourlyRate;
|
||||
|
||||
/// Total pay for this shift in cents.
|
||||
final int totalRateCents;
|
||||
|
||||
/// Total pay for this shift in dollars.
|
||||
final double totalRate;
|
||||
|
||||
/// Payment processing status.
|
||||
final PaymentStatus paymentStatus;
|
||||
|
||||
/// Assignment status (should always be `completed` for this class).
|
||||
final AssignmentStatus status;
|
||||
|
||||
/// Timesheet status (e.g. `SUBMITTED`, `APPROVED`, `PAID`, or null).
|
||||
final String? timesheetStatus;
|
||||
|
||||
/// Serialises to JSON.
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
@@ -63,9 +107,17 @@ class CompletedShift extends Equatable {
|
||||
'shiftId': shiftId,
|
||||
'title': title,
|
||||
'location': location,
|
||||
'clientName': clientName,
|
||||
'date': date.toIso8601String(),
|
||||
'startTime': startTime.toIso8601String(),
|
||||
'endTime': endTime.toIso8601String(),
|
||||
'minutesWorked': minutesWorked,
|
||||
'hourlyRateCents': hourlyRateCents,
|
||||
'hourlyRate': hourlyRate,
|
||||
'totalRateCents': totalRateCents,
|
||||
'totalRate': totalRate,
|
||||
'paymentStatus': paymentStatus.toJson(),
|
||||
'timesheetStatus': timesheetStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,8 +127,17 @@ class CompletedShift extends Equatable {
|
||||
shiftId,
|
||||
title,
|
||||
location,
|
||||
clientName,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
minutesWorked,
|
||||
hourlyRateCents,
|
||||
hourlyRate,
|
||||
totalRateCents,
|
||||
totalRate,
|
||||
paymentStatus,
|
||||
timesheetStatus,
|
||||
status,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -12,11 +12,13 @@ class OpenShift extends Equatable {
|
||||
required this.shiftId,
|
||||
required this.roleId,
|
||||
required this.roleName,
|
||||
this.clientName = '',
|
||||
required this.location,
|
||||
required this.date,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.hourlyRateCents,
|
||||
required this.hourlyRate,
|
||||
required this.orderType,
|
||||
required this.instantBook,
|
||||
required this.requiredWorkerCount,
|
||||
@@ -28,11 +30,13 @@ class OpenShift extends Equatable {
|
||||
shiftId: json['shiftId'] as String,
|
||||
roleId: json['roleId'] as String,
|
||||
roleName: json['roleName'] as String,
|
||||
clientName: json['clientName'] as String? ?? '',
|
||||
location: json['location'] as String? ?? '',
|
||||
date: DateTime.parse(json['date'] as String),
|
||||
startTime: DateTime.parse(json['startTime'] as String),
|
||||
endTime: DateTime.parse(json['endTime'] as String),
|
||||
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
||||
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
|
||||
orderType: OrderType.fromJson(json['orderType'] as String?),
|
||||
instantBook: json['instantBook'] as bool? ?? false,
|
||||
requiredWorkerCount: json['requiredWorkerCount'] as int? ?? 1,
|
||||
@@ -48,6 +52,9 @@ class OpenShift extends Equatable {
|
||||
/// Display name of the role.
|
||||
final String roleName;
|
||||
|
||||
/// Name of the client/business offering this shift.
|
||||
final String clientName;
|
||||
|
||||
/// Human-readable location label.
|
||||
final String location;
|
||||
|
||||
@@ -63,6 +70,9 @@ class OpenShift extends Equatable {
|
||||
/// Pay rate in cents per hour.
|
||||
final int hourlyRateCents;
|
||||
|
||||
/// Pay rate in dollars per hour.
|
||||
final double hourlyRate;
|
||||
|
||||
/// Order type.
|
||||
final OrderType orderType;
|
||||
|
||||
@@ -78,11 +88,13 @@ class OpenShift extends Equatable {
|
||||
'shiftId': shiftId,
|
||||
'roleId': roleId,
|
||||
'roleName': roleName,
|
||||
'clientName': clientName,
|
||||
'location': location,
|
||||
'date': date.toIso8601String(),
|
||||
'startTime': startTime.toIso8601String(),
|
||||
'endTime': endTime.toIso8601String(),
|
||||
'hourlyRateCents': hourlyRateCents,
|
||||
'hourlyRate': hourlyRate,
|
||||
'orderType': orderType.toJson(),
|
||||
'instantBook': instantBook,
|
||||
'requiredWorkerCount': requiredWorkerCount,
|
||||
@@ -94,11 +106,13 @@ class OpenShift extends Equatable {
|
||||
shiftId,
|
||||
roleId,
|
||||
roleName,
|
||||
clientName,
|
||||
location,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
hourlyRateCents,
|
||||
hourlyRate,
|
||||
orderType,
|
||||
instantBook,
|
||||
requiredWorkerCount,
|
||||
|
||||
@@ -11,7 +11,7 @@ class Shift extends Equatable {
|
||||
/// Creates a [Shift].
|
||||
const Shift({
|
||||
required this.id,
|
||||
required this.orderId,
|
||||
this.orderId,
|
||||
required this.title,
|
||||
required this.status,
|
||||
required this.startsAt,
|
||||
@@ -25,19 +25,39 @@ class Shift extends Equatable {
|
||||
required this.requiredWorkers,
|
||||
required this.assignedWorkers,
|
||||
this.notes,
|
||||
this.clockInMode,
|
||||
this.allowClockInOverride,
|
||||
this.nfcTagId,
|
||||
this.clientName,
|
||||
this.roleName,
|
||||
});
|
||||
|
||||
/// Deserialises from the V2 API JSON response.
|
||||
///
|
||||
/// Supports both the standard shift JSON shape (`id`, `startsAt`, `endsAt`)
|
||||
/// and the today-shifts endpoint shape (`shiftId`, `startTime`, `endTime`).
|
||||
factory Shift.fromJson(Map<String, dynamic> json) {
|
||||
final String? clientName = json['clientName'] as String?;
|
||||
final String? roleName = json['roleName'] as String?;
|
||||
|
||||
return Shift(
|
||||
id: json['id'] as String,
|
||||
orderId: json['orderId'] as String,
|
||||
title: json['title'] as String? ?? '',
|
||||
id: json['id'] as String? ?? json['shiftId'] as String,
|
||||
orderId: json['orderId'] as String?,
|
||||
title: json['title'] as String? ??
|
||||
roleName ??
|
||||
clientName ??
|
||||
'',
|
||||
status: ShiftStatus.fromJson(json['status'] as String?),
|
||||
startsAt: DateTime.parse(json['startsAt'] as String),
|
||||
endsAt: DateTime.parse(json['endsAt'] as String),
|
||||
startsAt: DateTime.parse(
|
||||
json['startsAt'] as String? ?? json['startTime'] as String,
|
||||
),
|
||||
endsAt: DateTime.parse(
|
||||
json['endsAt'] as String? ?? json['endTime'] as String,
|
||||
),
|
||||
timezone: json['timezone'] as String? ?? 'UTC',
|
||||
locationName: json['locationName'] as String?,
|
||||
locationName: json['locationName'] as String? ??
|
||||
json['locationAddress'] as String? ??
|
||||
json['location'] as String?,
|
||||
locationAddress: json['locationAddress'] as String?,
|
||||
latitude: parseDouble(json['latitude']),
|
||||
longitude: parseDouble(json['longitude']),
|
||||
@@ -45,14 +65,19 @@ class Shift extends Equatable {
|
||||
requiredWorkers: json['requiredWorkers'] as int? ?? 1,
|
||||
assignedWorkers: json['assignedWorkers'] as int? ?? 0,
|
||||
notes: json['notes'] as String?,
|
||||
clockInMode: json['clockInMode'] as String?,
|
||||
allowClockInOverride: json['allowClockInOverride'] as bool?,
|
||||
nfcTagId: json['nfcTagId'] as String?,
|
||||
clientName: clientName,
|
||||
roleName: roleName,
|
||||
);
|
||||
}
|
||||
|
||||
/// The shift row id.
|
||||
final String id;
|
||||
|
||||
/// The parent order id.
|
||||
final String orderId;
|
||||
/// The parent order id (may be null for today-shifts endpoint).
|
||||
final String? orderId;
|
||||
|
||||
/// Display title.
|
||||
final String title;
|
||||
@@ -93,6 +118,21 @@ class Shift extends Equatable {
|
||||
/// Free-form notes for the shift.
|
||||
final String? notes;
|
||||
|
||||
/// Clock-in mode for this shift (`NFC_REQUIRED`, `GEO_REQUIRED`, `EITHER`).
|
||||
final String? clockInMode;
|
||||
|
||||
/// Whether the worker is allowed to override the clock-in method.
|
||||
final bool? allowClockInOverride;
|
||||
|
||||
/// NFC tag identifier for NFC-based clock-in.
|
||||
final String? nfcTagId;
|
||||
|
||||
/// Name of the client (business) this shift belongs to.
|
||||
final String? clientName;
|
||||
|
||||
/// Name of the role the worker is assigned for this shift.
|
||||
final String? roleName;
|
||||
|
||||
/// Serialises to JSON.
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
@@ -111,6 +151,11 @@ class Shift extends Equatable {
|
||||
'requiredWorkers': requiredWorkers,
|
||||
'assignedWorkers': assignedWorkers,
|
||||
'notes': notes,
|
||||
'clockInMode': clockInMode,
|
||||
'allowClockInOverride': allowClockInOverride,
|
||||
'nfcTagId': nfcTagId,
|
||||
'clientName': clientName,
|
||||
'roleName': roleName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -140,5 +185,10 @@ class Shift extends Equatable {
|
||||
requiredWorkers,
|
||||
assignedWorkers,
|
||||
notes,
|
||||
clockInMode,
|
||||
allowClockInOverride,
|
||||
nfcTagId,
|
||||
clientName,
|
||||
roleName,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/src/entities/enums/application_status.dart';
|
||||
import 'package:krow_domain/src/entities/enums/assignment_status.dart';
|
||||
import 'package:krow_domain/src/entities/enums/order_type.dart';
|
||||
import 'package:krow_domain/src/entities/shifts/shift.dart';
|
||||
|
||||
/// Full detail view of a shift for the staff member.
|
||||
///
|
||||
@@ -18,17 +19,27 @@ class ShiftDetail extends Equatable {
|
||||
this.description,
|
||||
required this.location,
|
||||
this.address,
|
||||
required this.clientName,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required this.date,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.roleId,
|
||||
required this.roleName,
|
||||
required this.hourlyRateCents,
|
||||
required this.hourlyRate,
|
||||
required this.totalRateCents,
|
||||
required this.totalRate,
|
||||
required this.orderType,
|
||||
required this.requiredCount,
|
||||
required this.confirmedCount,
|
||||
this.assignmentStatus,
|
||||
this.applicationStatus,
|
||||
this.clockInMode,
|
||||
required this.allowClockInOverride,
|
||||
this.geofenceRadiusMeters,
|
||||
this.nfcTagId,
|
||||
});
|
||||
|
||||
/// Deserialises from the V2 API JSON response.
|
||||
@@ -39,12 +50,18 @@ class ShiftDetail extends Equatable {
|
||||
description: json['description'] as String?,
|
||||
location: json['location'] as String? ?? '',
|
||||
address: json['address'] as String?,
|
||||
clientName: json['clientName'] as String? ?? '',
|
||||
latitude: Shift.parseDouble(json['latitude']),
|
||||
longitude: Shift.parseDouble(json['longitude']),
|
||||
date: DateTime.parse(json['date'] as String),
|
||||
startTime: DateTime.parse(json['startTime'] as String),
|
||||
endTime: DateTime.parse(json['endTime'] as String),
|
||||
roleId: json['roleId'] as String,
|
||||
roleName: json['roleName'] as String,
|
||||
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
||||
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
|
||||
totalRateCents: json['totalRateCents'] as int? ?? 0,
|
||||
totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0,
|
||||
orderType: OrderType.fromJson(json['orderType'] as String?),
|
||||
requiredCount: json['requiredCount'] as int? ?? 1,
|
||||
confirmedCount: json['confirmedCount'] as int? ?? 0,
|
||||
@@ -54,6 +71,10 @@ class ShiftDetail extends Equatable {
|
||||
applicationStatus: json['applicationStatus'] != null
|
||||
? ApplicationStatus.fromJson(json['applicationStatus'] as String?)
|
||||
: null,
|
||||
clockInMode: json['clockInMode'] as String?,
|
||||
allowClockInOverride: json['allowClockInOverride'] as bool? ?? false,
|
||||
geofenceRadiusMeters: json['geofenceRadiusMeters'] as int?,
|
||||
nfcTagId: json['nfcTagId'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +93,15 @@ class ShiftDetail extends Equatable {
|
||||
/// Street address of the shift location.
|
||||
final String? address;
|
||||
|
||||
/// Name of the client / business for this shift.
|
||||
final String clientName;
|
||||
|
||||
/// Latitude for map display and geofence validation.
|
||||
final double? latitude;
|
||||
|
||||
/// Longitude for map display and geofence validation.
|
||||
final double? longitude;
|
||||
|
||||
/// Date of the shift (same as startTime, kept for display grouping).
|
||||
final DateTime date;
|
||||
|
||||
@@ -90,6 +120,15 @@ class ShiftDetail extends Equatable {
|
||||
/// Pay rate in cents per hour.
|
||||
final int hourlyRateCents;
|
||||
|
||||
/// Pay rate in dollars per hour.
|
||||
final double hourlyRate;
|
||||
|
||||
/// Total pay for this shift in cents.
|
||||
final int totalRateCents;
|
||||
|
||||
/// Total pay for this shift in dollars.
|
||||
final double totalRate;
|
||||
|
||||
/// Order type.
|
||||
final OrderType orderType;
|
||||
|
||||
@@ -105,6 +144,26 @@ class ShiftDetail extends Equatable {
|
||||
/// Current worker's application status, if applied.
|
||||
final ApplicationStatus? applicationStatus;
|
||||
|
||||
/// Clock-in mode for this shift (`NFC_REQUIRED`, `GEO_REQUIRED`, `EITHER`).
|
||||
final String? clockInMode;
|
||||
|
||||
/// Whether the worker is allowed to override the clock-in method.
|
||||
final bool allowClockInOverride;
|
||||
|
||||
/// Geofence radius in meters for clock-in validation.
|
||||
final int? geofenceRadiusMeters;
|
||||
|
||||
/// NFC tag identifier for NFC-based clock-in.
|
||||
final String? nfcTagId;
|
||||
|
||||
/// Duration of the shift in hours.
|
||||
double get durationHours {
|
||||
return endTime.difference(startTime).inMinutes / 60;
|
||||
}
|
||||
|
||||
/// Estimated total pay in dollars.
|
||||
double get estimatedTotal => hourlyRate * durationHours;
|
||||
|
||||
/// Serialises to JSON.
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
@@ -113,17 +172,27 @@ class ShiftDetail extends Equatable {
|
||||
'description': description,
|
||||
'location': location,
|
||||
'address': address,
|
||||
'clientName': clientName,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'date': date.toIso8601String(),
|
||||
'startTime': startTime.toIso8601String(),
|
||||
'endTime': endTime.toIso8601String(),
|
||||
'roleId': roleId,
|
||||
'roleName': roleName,
|
||||
'hourlyRateCents': hourlyRateCents,
|
||||
'hourlyRate': hourlyRate,
|
||||
'totalRateCents': totalRateCents,
|
||||
'totalRate': totalRate,
|
||||
'orderType': orderType.toJson(),
|
||||
'requiredCount': requiredCount,
|
||||
'confirmedCount': confirmedCount,
|
||||
'assignmentStatus': assignmentStatus?.toJson(),
|
||||
'applicationStatus': applicationStatus?.toJson(),
|
||||
'clockInMode': clockInMode,
|
||||
'allowClockInOverride': allowClockInOverride,
|
||||
'geofenceRadiusMeters': geofenceRadiusMeters,
|
||||
'nfcTagId': nfcTagId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,16 +203,26 @@ class ShiftDetail extends Equatable {
|
||||
description,
|
||||
location,
|
||||
address,
|
||||
clientName,
|
||||
latitude,
|
||||
longitude,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
roleId,
|
||||
roleName,
|
||||
hourlyRateCents,
|
||||
hourlyRate,
|
||||
totalRateCents,
|
||||
totalRate,
|
||||
orderType,
|
||||
requiredCount,
|
||||
confirmedCount,
|
||||
assignmentStatus,
|
||||
applicationStatus,
|
||||
clockInMode,
|
||||
allowClockInOverride,
|
||||
geofenceRadiusMeters,
|
||||
nfcTagId,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ class TodayShift extends Equatable {
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.attendanceStatus,
|
||||
this.clientName = '',
|
||||
this.hourlyRateCents = 0,
|
||||
this.hourlyRate = 0.0,
|
||||
this.totalRateCents = 0,
|
||||
this.totalRate = 0.0,
|
||||
this.locationAddress,
|
||||
this.clockInAt,
|
||||
});
|
||||
|
||||
@@ -30,6 +36,12 @@ class TodayShift extends Equatable {
|
||||
startTime: DateTime.parse(json['startTime'] as String),
|
||||
endTime: DateTime.parse(json['endTime'] as String),
|
||||
attendanceStatus: AttendanceStatusType.fromJson(json['attendanceStatus'] as String?),
|
||||
clientName: json['clientName'] as String? ?? '',
|
||||
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
||||
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
|
||||
totalRateCents: json['totalRateCents'] as int? ?? 0,
|
||||
totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0,
|
||||
locationAddress: json['locationAddress'] as String?,
|
||||
clockInAt: json['clockInAt'] != null
|
||||
? DateTime.parse(json['clockInAt'] as String)
|
||||
: null,
|
||||
@@ -48,6 +60,24 @@ class TodayShift extends Equatable {
|
||||
/// Human-readable location label (clock-point or shift location).
|
||||
final String location;
|
||||
|
||||
/// Name of the client / business for this shift.
|
||||
final String clientName;
|
||||
|
||||
/// Pay rate in cents per hour.
|
||||
final int hourlyRateCents;
|
||||
|
||||
/// Pay rate in dollars per hour.
|
||||
final double hourlyRate;
|
||||
|
||||
/// Total pay for this shift in cents.
|
||||
final int totalRateCents;
|
||||
|
||||
/// Total pay for this shift in dollars.
|
||||
final double totalRate;
|
||||
|
||||
/// Full street address of the shift location, if available.
|
||||
final String? locationAddress;
|
||||
|
||||
/// Scheduled start time.
|
||||
final DateTime startTime;
|
||||
|
||||
@@ -67,6 +97,12 @@ class TodayShift extends Equatable {
|
||||
'shiftId': shiftId,
|
||||
'roleName': roleName,
|
||||
'location': location,
|
||||
'clientName': clientName,
|
||||
'hourlyRateCents': hourlyRateCents,
|
||||
'hourlyRate': hourlyRate,
|
||||
'totalRateCents': totalRateCents,
|
||||
'totalRate': totalRate,
|
||||
'locationAddress': locationAddress,
|
||||
'startTime': startTime.toIso8601String(),
|
||||
'endTime': endTime.toIso8601String(),
|
||||
'attendanceStatus': attendanceStatus.toJson(),
|
||||
@@ -80,6 +116,12 @@ class TodayShift extends Equatable {
|
||||
shiftId,
|
||||
roleName,
|
||||
location,
|
||||
clientName,
|
||||
hourlyRateCents,
|
||||
hourlyRate,
|
||||
totalRateCents,
|
||||
totalRate,
|
||||
locationAddress,
|
||||
startTime,
|
||||
endTime,
|
||||
attendanceStatus,
|
||||
|
||||
@@ -33,7 +33,10 @@ class ClientAuthenticationModule extends Module {
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addLazySingleton<AuthRepositoryInterface>(
|
||||
() => AuthRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||
() => AuthRepositoryImpl(
|
||||
apiService: i.get<BaseApiService>(),
|
||||
firebaseAuthService: i.get<FirebaseAuthService>(),
|
||||
),
|
||||
);
|
||||
|
||||
// UseCases
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:client_authentication/src/domain/repositories/auth_repository_interface.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart'
|
||||
show
|
||||
@@ -10,7 +9,6 @@ import 'package:krow_domain/krow_domain.dart'
|
||||
AppException,
|
||||
BaseApiService,
|
||||
ClientSession,
|
||||
InvalidCredentialsException,
|
||||
NetworkException,
|
||||
PasswordMismatchException,
|
||||
SignInFailedException,
|
||||
@@ -21,20 +19,23 @@ import 'package:krow_domain/krow_domain.dart'
|
||||
|
||||
/// Production implementation of the [AuthRepositoryInterface] for the client app.
|
||||
///
|
||||
/// Uses Firebase Auth client-side for sign-in (to maintain local auth state for
|
||||
/// the [AuthInterceptor]), then calls V2 `GET /auth/session` to retrieve
|
||||
/// business context. Sign-up provisioning (tenant, business, memberships) is
|
||||
/// handled entirely server-side by the V2 API.
|
||||
/// Uses [FirebaseAuthService] from core for local Firebase sign-in (to maintain
|
||||
/// local auth state for the [AuthInterceptor]), then calls V2 `GET /auth/session`
|
||||
/// to retrieve business context. Sign-up provisioning (tenant, business,
|
||||
/// memberships) is handled entirely server-side by the V2 API.
|
||||
class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
/// Creates an [AuthRepositoryImpl] with the given [BaseApiService].
|
||||
AuthRepositoryImpl({required BaseApiService apiService})
|
||||
: _apiService = apiService;
|
||||
/// Creates an [AuthRepositoryImpl] with the given dependencies.
|
||||
AuthRepositoryImpl({
|
||||
required BaseApiService apiService,
|
||||
required FirebaseAuthService firebaseAuthService,
|
||||
}) : _apiService = apiService,
|
||||
_firebaseAuthService = firebaseAuthService;
|
||||
|
||||
/// The V2 API service for backend calls.
|
||||
final BaseApiService _apiService;
|
||||
|
||||
/// Firebase Auth instance for client-side sign-in/sign-up.
|
||||
firebase.FirebaseAuth get _auth => firebase.FirebaseAuth.instance;
|
||||
/// Core Firebase Auth service abstraction.
|
||||
final FirebaseAuthService _firebaseAuthService;
|
||||
|
||||
@override
|
||||
Future<User> signInWithEmail({
|
||||
@@ -42,38 +43,26 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
required String password,
|
||||
}) async {
|
||||
try {
|
||||
// Step 1: Call V2 sign-in endpoint — server handles Firebase Auth
|
||||
// Step 1: Call V2 sign-in endpoint -- server handles Firebase Auth
|
||||
// via Identity Toolkit and returns a full auth envelope.
|
||||
final ApiResponse response = await _apiService.post(
|
||||
AuthEndpoints.clientSignIn,
|
||||
data: <String, dynamic>{
|
||||
'email': email,
|
||||
'password': password,
|
||||
},
|
||||
data: <String, dynamic>{'email': email, 'password': password},
|
||||
);
|
||||
|
||||
final Map<String, dynamic> body =
|
||||
response.data as Map<String, dynamic>;
|
||||
final Map<String, dynamic> body = response.data as Map<String, dynamic>;
|
||||
|
||||
// Step 2: Sign in locally so AuthInterceptor can attach Bearer tokens
|
||||
// to subsequent requests. The V2 API already validated credentials, so
|
||||
// email/password sign-in establishes the local Firebase Auth state.
|
||||
final firebase.UserCredential credential =
|
||||
await _auth.signInWithEmailAndPassword(
|
||||
await _firebaseAuthService.signInWithEmailAndPassword(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
|
||||
final firebase.User? firebaseUser = credential.user;
|
||||
if (firebaseUser == null) {
|
||||
throw const SignInFailedException(
|
||||
technicalMessage: 'Local Firebase sign-in failed after V2 sign-in',
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Populate session store from the V2 auth envelope directly
|
||||
// (no need for a separate GET /auth/session call).
|
||||
return _populateStoreFromAuthEnvelope(body, firebaseUser, email);
|
||||
return _populateStoreFromAuthEnvelope(body, email);
|
||||
} on AppException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
@@ -106,38 +95,34 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
// Step 2: Sign in locally to Firebase Auth so AuthInterceptor works
|
||||
// for subsequent requests. The V2 API already created the Firebase
|
||||
// account, so this should succeed.
|
||||
final firebase.UserCredential credential =
|
||||
await _auth.signInWithEmailAndPassword(
|
||||
try {
|
||||
await _firebaseAuthService.signInWithEmailAndPassword(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
|
||||
final firebase.User? firebaseUser = credential.user;
|
||||
if (firebaseUser == null) {
|
||||
} on SignInFailedException {
|
||||
throw const SignUpFailedException(
|
||||
technicalMessage: 'Local Firebase sign-in failed after V2 sign-up',
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Populate store from the sign-up response envelope.
|
||||
return _populateStoreFromAuthEnvelope(body, firebaseUser, email);
|
||||
} on firebase.FirebaseAuthException catch (e) {
|
||||
if (e.code == 'email-already-in-use') {
|
||||
throw AccountExistsException(
|
||||
technicalMessage: 'Firebase: ${e.message}',
|
||||
);
|
||||
} else if (e.code == 'weak-password') {
|
||||
throw WeakPasswordException(technicalMessage: 'Firebase: ${e.message}');
|
||||
} else if (e.code == 'network-request-failed') {
|
||||
throw NetworkException(technicalMessage: 'Firebase: ${e.message}');
|
||||
} else {
|
||||
throw SignUpFailedException(
|
||||
technicalMessage: 'Firebase auth error: ${e.message}',
|
||||
);
|
||||
}
|
||||
return _populateStoreFromAuthEnvelope(body, email);
|
||||
} on AppException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
// Map common Firebase-originated errors from the V2 API response
|
||||
// to domain exceptions.
|
||||
final String errorMessage = e.toString();
|
||||
if (errorMessage.contains('EMAIL_EXISTS') ||
|
||||
errorMessage.contains('email-already-in-use')) {
|
||||
throw AccountExistsException(technicalMessage: errorMessage);
|
||||
} else if (errorMessage.contains('WEAK_PASSWORD') ||
|
||||
errorMessage.contains('weak-password')) {
|
||||
throw WeakPasswordException(technicalMessage: errorMessage);
|
||||
} else if (errorMessage.contains('network-request-failed')) {
|
||||
throw NetworkException(technicalMessage: errorMessage);
|
||||
}
|
||||
throw SignUpFailedException(technicalMessage: 'Unexpected error: $e');
|
||||
}
|
||||
}
|
||||
@@ -155,16 +140,13 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
// Step 1: Call V2 sign-out endpoint for server-side token revocation.
|
||||
await _apiService.post(AuthEndpoints.clientSignOut);
|
||||
} catch (e) {
|
||||
developer.log(
|
||||
'V2 sign-out request failed: $e',
|
||||
name: 'AuthRepository',
|
||||
);
|
||||
developer.log('V2 sign-out request failed: $e', name: 'AuthRepository');
|
||||
// Continue with local sign-out even if server-side fails.
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 2: Sign out from local Firebase Auth.
|
||||
await _auth.signOut();
|
||||
// Step 2: Sign out from local Firebase Auth via core service.
|
||||
await _firebaseAuthService.signOut();
|
||||
} catch (e) {
|
||||
throw Exception('Error signing out locally: $e');
|
||||
}
|
||||
@@ -181,7 +163,6 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
/// returns a domain [User].
|
||||
User _populateStoreFromAuthEnvelope(
|
||||
Map<String, dynamic> envelope,
|
||||
firebase.User firebaseUser,
|
||||
String fallbackEmail,
|
||||
) {
|
||||
final Map<String, dynamic>? userJson =
|
||||
@@ -202,14 +183,15 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
'userId': userJson['id'] ?? userJson['userId'],
|
||||
},
|
||||
};
|
||||
final ClientSession clientSession =
|
||||
ClientSession.fromJson(normalisedEnvelope);
|
||||
final ClientSession clientSession = ClientSession.fromJson(
|
||||
normalisedEnvelope,
|
||||
);
|
||||
ClientSessionStore.instance.setSession(clientSession);
|
||||
}
|
||||
|
||||
final String userId =
|
||||
userJson?['id'] as String? ?? firebaseUser.uid;
|
||||
final String? email = userJson?['email'] as String? ?? fallbackEmail;
|
||||
final String userId = userJson?['id'] as String? ??
|
||||
(_firebaseAuthService.currentUserUid ?? '');
|
||||
final String email = userJson?['email'] as String? ?? fallbackEmail;
|
||||
|
||||
return User(
|
||||
id: userId,
|
||||
|
||||
@@ -14,7 +14,6 @@ dependencies:
|
||||
flutter_bloc: ^8.1.0
|
||||
flutter_modular: ^6.3.0
|
||||
equatable: ^2.0.5
|
||||
firebase_auth: ^6.1.2
|
||||
|
||||
# Architecture Packages
|
||||
design_system:
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:billing/src/data/repositories_impl/billing_repository_impl.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository_interface.dart';
|
||||
import 'package:billing/src/domain/usecases/approve_invoice.dart';
|
||||
import 'package:billing/src/domain/usecases/dispute_invoice.dart';
|
||||
import 'package:billing/src/domain/usecases/get_bank_accounts.dart';
|
||||
@@ -29,8 +29,8 @@ class BillingModule extends Module {
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addLazySingleton<BillingRepository>(
|
||||
() => BillingRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||
i.addLazySingleton<BillingRepositoryInterface>(
|
||||
() => BillingRepositoryInterfaceImpl(apiService: i.get<BaseApiService>()),
|
||||
);
|
||||
|
||||
// Use Cases
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository_interface.dart';
|
||||
|
||||
/// Implementation of [BillingRepository] using the V2 REST API.
|
||||
/// Implementation of [BillingRepositoryInterface] using the V2 REST API.
|
||||
///
|
||||
/// All backend calls go through [BaseApiService] with [ClientEndpoints].
|
||||
class BillingRepositoryImpl implements BillingRepository {
|
||||
/// Creates a [BillingRepositoryImpl].
|
||||
BillingRepositoryImpl({required BaseApiService apiService})
|
||||
class BillingRepositoryInterfaceImpl implements BillingRepositoryInterface {
|
||||
/// Creates a [BillingRepositoryInterfaceImpl].
|
||||
BillingRepositoryInterfaceImpl({required BaseApiService apiService})
|
||||
: _apiService = apiService;
|
||||
|
||||
/// The API service used for all HTTP requests.
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
/// This interface defines the contract for accessing billing-related data,
|
||||
/// acting as a boundary between the Domain and Data layers.
|
||||
/// It allows the Domain layer to remain independent of specific data sources.
|
||||
abstract class BillingRepository {
|
||||
abstract class BillingRepositoryInterface {
|
||||
/// Fetches bank accounts associated with the business.
|
||||
Future<List<BillingAccount>> getBankAccounts();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository_interface.dart';
|
||||
|
||||
/// Use case for approving an invoice.
|
||||
class ApproveInvoiceUseCase extends UseCase<String, void> {
|
||||
@@ -8,7 +8,7 @@ class ApproveInvoiceUseCase extends UseCase<String, void> {
|
||||
ApproveInvoiceUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
final BillingRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(String input) => _repository.approveInvoice(input);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository_interface.dart';
|
||||
|
||||
/// Params for [DisputeInvoiceUseCase].
|
||||
class DisputeInvoiceParams {
|
||||
@@ -20,7 +20,7 @@ class DisputeInvoiceUseCase extends UseCase<DisputeInvoiceParams, void> {
|
||||
DisputeInvoiceUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
final BillingRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(DisputeInvoiceParams input) =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository_interface.dart';
|
||||
|
||||
/// Use case for fetching the bank accounts associated with the business.
|
||||
class GetBankAccountsUseCase extends NoInputUseCase<List<BillingAccount>> {
|
||||
@@ -9,7 +9,7 @@ class GetBankAccountsUseCase extends NoInputUseCase<List<BillingAccount>> {
|
||||
GetBankAccountsUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
final BillingRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<List<BillingAccount>> call() => _repository.getBankAccounts();
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository_interface.dart';
|
||||
|
||||
/// Use case for fetching the current bill amount in cents.
|
||||
///
|
||||
/// Delegates data retrieval to the [BillingRepository].
|
||||
/// Delegates data retrieval to the [BillingRepositoryInterface].
|
||||
class GetCurrentBillAmountUseCase extends NoInputUseCase<int> {
|
||||
/// Creates a [GetCurrentBillAmountUseCase].
|
||||
GetCurrentBillAmountUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
final BillingRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<int> call() => _repository.getCurrentBillCents();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository_interface.dart';
|
||||
|
||||
/// Use case for fetching the invoice history.
|
||||
///
|
||||
@@ -11,7 +11,7 @@ class GetInvoiceHistoryUseCase extends NoInputUseCase<List<Invoice>> {
|
||||
GetInvoiceHistoryUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
final BillingRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<List<Invoice>> call() => _repository.getInvoiceHistory();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository_interface.dart';
|
||||
|
||||
/// Use case for fetching the pending invoices.
|
||||
///
|
||||
@@ -11,7 +11,7 @@ class GetPendingInvoicesUseCase extends NoInputUseCase<List<Invoice>> {
|
||||
GetPendingInvoicesUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
final BillingRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<List<Invoice>> call() => _repository.getPendingInvoices();
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository_interface.dart';
|
||||
|
||||
/// Use case for fetching the savings amount in cents.
|
||||
///
|
||||
/// Delegates data retrieval to the [BillingRepository].
|
||||
/// Delegates data retrieval to the [BillingRepositoryInterface].
|
||||
class GetSavingsAmountUseCase extends NoInputUseCase<int> {
|
||||
/// Creates a [GetSavingsAmountUseCase].
|
||||
GetSavingsAmountUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
final BillingRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<int> call() => _repository.getSavingsCents();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository_interface.dart';
|
||||
|
||||
/// Parameters for [GetSpendBreakdownUseCase].
|
||||
class SpendBreakdownParams {
|
||||
@@ -20,14 +20,14 @@ class SpendBreakdownParams {
|
||||
|
||||
/// Use case for fetching the spending breakdown by category.
|
||||
///
|
||||
/// Delegates data retrieval to the [BillingRepository].
|
||||
/// Delegates data retrieval to the [BillingRepositoryInterface].
|
||||
class GetSpendBreakdownUseCase
|
||||
extends UseCase<SpendBreakdownParams, List<SpendItem>> {
|
||||
/// Creates a [GetSpendBreakdownUseCase].
|
||||
GetSpendBreakdownUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
final BillingRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<List<SpendItem>> call(SpendBreakdownParams input) =>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
@@ -14,6 +12,9 @@ import 'package:billing/src/presentation/blocs/billing_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||
|
||||
/// BLoC for managing billing state and data loading.
|
||||
///
|
||||
/// Fetches billing summary data (current bill, savings, invoices,
|
||||
/// spend breakdown, bank accounts) and manages period tab selection.
|
||||
class BillingBloc extends Bloc<BillingEvent, BillingState>
|
||||
with BlocErrorHandler<BillingState> {
|
||||
/// Creates a [BillingBloc] with the given use cases.
|
||||
@@ -35,52 +36,79 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
||||
on<BillingPeriodChanged>(_onPeriodChanged);
|
||||
}
|
||||
|
||||
/// Use case for fetching bank accounts.
|
||||
final GetBankAccountsUseCase _getBankAccounts;
|
||||
|
||||
/// Use case for fetching the current bill amount.
|
||||
final GetCurrentBillAmountUseCase _getCurrentBillAmount;
|
||||
|
||||
/// Use case for fetching the savings amount.
|
||||
final GetSavingsAmountUseCase _getSavingsAmount;
|
||||
|
||||
/// Use case for fetching pending invoices.
|
||||
final GetPendingInvoicesUseCase _getPendingInvoices;
|
||||
|
||||
/// Use case for fetching invoice history.
|
||||
final GetInvoiceHistoryUseCase _getInvoiceHistory;
|
||||
|
||||
/// Use case for fetching spending breakdown.
|
||||
final GetSpendBreakdownUseCase _getSpendBreakdown;
|
||||
|
||||
/// Executes [loader] and returns null on failure, logging the error.
|
||||
Future<T?> _loadSafe<T>(Future<T> Function() loader) async {
|
||||
try {
|
||||
return await loader();
|
||||
} catch (e, stackTrace) {
|
||||
developer.log(
|
||||
'Partial billing load failed: $e',
|
||||
name: 'BillingBloc',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads all billing data concurrently.
|
||||
///
|
||||
/// Uses [handleError] to surface errors to the UI via state
|
||||
/// instead of silently swallowing them. Individual data fetches
|
||||
/// use [handleErrorWithResult] so partial failures populate
|
||||
/// with defaults rather than failing the entire load.
|
||||
Future<void> _onLoadStarted(
|
||||
BillingLoadStarted event,
|
||||
Emitter<BillingState> emit,
|
||||
) async {
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
emit(state.copyWith(status: BillingStatus.loading));
|
||||
|
||||
final SpendBreakdownParams spendParams = _dateRangeFor(state.periodTab);
|
||||
final SpendBreakdownParams spendParams =
|
||||
_dateRangeFor(state.periodTab);
|
||||
|
||||
final List<Object?> results = await Future.wait<Object?>(
|
||||
<Future<Object?>>[
|
||||
_loadSafe<int>(() => _getCurrentBillAmount.call()),
|
||||
_loadSafe<int>(() => _getSavingsAmount.call()),
|
||||
_loadSafe<List<Invoice>>(() => _getPendingInvoices.call()),
|
||||
_loadSafe<List<Invoice>>(() => _getInvoiceHistory.call()),
|
||||
_loadSafe<List<SpendItem>>(() => _getSpendBreakdown.call(spendParams)),
|
||||
_loadSafe<List<BillingAccount>>(() => _getBankAccounts.call()),
|
||||
handleErrorWithResult<int>(
|
||||
action: () => _getCurrentBillAmount.call(),
|
||||
onError: (_) {},
|
||||
),
|
||||
handleErrorWithResult<int>(
|
||||
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? savingsCents = results[1] as int?;
|
||||
final List<Invoice>? pendingInvoices = results[2] as List<Invoice>?;
|
||||
final List<Invoice>? invoiceHistory = results[3] as List<Invoice>?;
|
||||
final List<SpendItem>? spendBreakdown = results[4] as List<SpendItem>?;
|
||||
final List<Invoice>? pendingInvoices =
|
||||
results[2] as List<Invoice>?;
|
||||
final List<Invoice>? invoiceHistory =
|
||||
results[3] as List<Invoice>?;
|
||||
final List<SpendItem>? spendBreakdown =
|
||||
results[4] as List<SpendItem>?;
|
||||
final List<BillingAccount>? bankAccounts =
|
||||
results[5] as List<BillingAccount>?;
|
||||
|
||||
@@ -95,6 +123,12 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
||||
bankAccounts: bankAccounts ?? state.bankAccounts,
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: BillingStatus.failure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onPeriodChanged(
|
||||
|
||||
@@ -56,7 +56,7 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
||||
final DateFormat formatter = DateFormat('EEEE, MMMM d');
|
||||
final String dateLabel = resolvedInvoice.dueDate != null
|
||||
? formatter.format(resolvedInvoice.dueDate!)
|
||||
: 'N/A';
|
||||
: 'N/A'; // TODO: localize
|
||||
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
@@ -85,7 +85,7 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
||||
bottomNavigationBar: Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
color: UiColors.primaryForeground,
|
||||
border: Border(
|
||||
top: BorderSide(color: UiColors.border.withValues(alpha: 0.5)),
|
||||
),
|
||||
|
||||
@@ -19,7 +19,7 @@ class BillingPageSkeleton extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
// Pending invoices section header
|
||||
const UiShimmerSectionHeader(),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
@@ -39,7 +39,7 @@ class BillingPageSkeleton extends StatelessWidget {
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 160, height: 16),
|
||||
SizedBox(height: UiConstants.space4),
|
||||
// Breakdown rows
|
||||
|
||||
@@ -10,7 +10,7 @@ class BreakdownRowSkeleton extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 100, height: 14),
|
||||
UiShimmerLine(width: 60, height: 14),
|
||||
],
|
||||
|
||||
@@ -16,10 +16,10 @@ class InvoiceCardSkeleton extends StatelessWidget {
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
UiShimmerBox(
|
||||
width: 72,
|
||||
height: 24,
|
||||
@@ -35,10 +35,10 @@ class InvoiceCardSkeleton extends StatelessWidget {
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 80, height: 10),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 100, height: 18),
|
||||
|
||||
@@ -95,8 +95,8 @@ class CompletionReviewActions extends StatelessWidget {
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) => AlertDialog(
|
||||
title: Text(t.client_billing.flag_dialog.title),
|
||||
surfaceTintColor: Colors.white,
|
||||
backgroundColor: Colors.white,
|
||||
surfaceTintColor: UiColors.primaryForeground,
|
||||
backgroundColor: UiColors.primaryForeground,
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
|
||||
@@ -23,7 +23,7 @@ class CompletionReviewSearchAndTabs extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF1F5F9),
|
||||
color: UiColors.muted,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: TextField(
|
||||
@@ -69,17 +69,17 @@ class CompletionReviewSearchAndTabs extends StatelessWidget {
|
||||
child: Container(
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFF2563EB) : Colors.white,
|
||||
color: isSelected ? UiColors.primary : UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(
|
||||
color: isSelected ? const Color(0xFF2563EB) : UiColors.border,
|
||||
color: isSelected ? UiColors.primary : UiColors.border,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
style: UiTypography.body2b.copyWith(
|
||||
color: isSelected ? Colors.white : UiColors.textSecondary,
|
||||
color: isSelected ? UiColors.primaryForeground : UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -15,7 +15,7 @@ class InvoicesListSkeleton extends StatelessWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
children: List.generate(4, (int index) {
|
||||
children: List<Widget>.generate(4, (int index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||
child: Container(
|
||||
@@ -26,10 +26,10 @@ class InvoicesListSkeleton extends StatelessWidget {
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
UiShimmerBox(
|
||||
width: 64,
|
||||
height: 22,
|
||||
@@ -47,10 +47,10 @@ class InvoicesListSkeleton extends StatelessWidget {
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 80, height: 10),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 100, height: 20),
|
||||
|
||||
@@ -33,7 +33,7 @@ class PendingInvoicesSection extends StatelessWidget {
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.orange,
|
||||
color: UiColors.textWarning,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
@@ -101,7 +101,7 @@ class PendingInvoiceCard extends StatelessWidget {
|
||||
final DateFormat formatter = DateFormat('EEEE, MMMM d');
|
||||
final String dateLabel = invoice.dueDate != null
|
||||
? formatter.format(invoice.dueDate!)
|
||||
: 'N/A';
|
||||
: 'N/A'; // TODO: localize
|
||||
final double amountDollars = invoice.amountCents / 100.0;
|
||||
|
||||
return Container(
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:client_coverage/src/data/repositories_impl/coverage_repository_impl.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart';
|
||||
import 'package:client_coverage/src/domain/usecases/cancel_late_worker_usecase.dart';
|
||||
import 'package:client_coverage/src/domain/usecases/get_coverage_stats_usecase.dart';
|
||||
import 'package:client_coverage/src/domain/usecases/get_shifts_for_date_usecase.dart';
|
||||
@@ -21,8 +21,8 @@ class CoverageModule extends Module {
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addLazySingleton<CoverageRepository>(
|
||||
() => CoverageRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||
i.addLazySingleton<CoverageRepositoryInterface>(
|
||||
() => CoverageRepositoryInterfaceImpl(apiService: i.get<BaseApiService>()),
|
||||
);
|
||||
|
||||
// Use Cases
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart';
|
||||
|
||||
/// V2 API implementation of [CoverageRepository].
|
||||
/// V2 API implementation of [CoverageRepositoryInterface].
|
||||
///
|
||||
/// Uses [BaseApiService] with [ClientEndpoints] for all backend access.
|
||||
class CoverageRepositoryImpl implements CoverageRepository {
|
||||
/// Creates a [CoverageRepositoryImpl].
|
||||
CoverageRepositoryImpl({required BaseApiService apiService})
|
||||
class CoverageRepositoryInterfaceImpl implements CoverageRepositoryInterface {
|
||||
/// Creates a [CoverageRepositoryInterfaceImpl].
|
||||
CoverageRepositoryInterfaceImpl({required BaseApiService apiService})
|
||||
: _apiService = apiService;
|
||||
|
||||
final BaseApiService _apiService;
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
///
|
||||
/// Defines the contract for accessing coverage data via the V2 REST API,
|
||||
/// acting as a boundary between the Domain and Data layers.
|
||||
abstract interface class CoverageRepository {
|
||||
abstract interface class CoverageRepositoryInterface {
|
||||
/// Fetches shifts with assigned workers for a specific [date].
|
||||
Future<List<ShiftWithWorkers>> getShiftsForDate({required DateTime date});
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'package:client_coverage/src/domain/arguments/cancel_late_worker_arguments.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart';
|
||||
|
||||
/// Use case for cancelling a late worker's assignment.
|
||||
///
|
||||
/// Delegates to [CoverageRepository] to cancel the assignment via V2 API.
|
||||
/// Delegates to [CoverageRepositoryInterface] to cancel the assignment via V2 API.
|
||||
class CancelLateWorkerUseCase
|
||||
implements UseCase<CancelLateWorkerArguments, void> {
|
||||
/// Creates a [CancelLateWorkerUseCase].
|
||||
CancelLateWorkerUseCase(this._repository);
|
||||
|
||||
final CoverageRepository _repository;
|
||||
final CoverageRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(CancelLateWorkerArguments arguments) {
|
||||
|
||||
@@ -2,17 +2,17 @@ import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:client_coverage/src/domain/arguments/get_coverage_stats_arguments.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart';
|
||||
|
||||
/// Use case for fetching aggregated coverage statistics for a specific date.
|
||||
///
|
||||
/// Delegates to [CoverageRepository] and returns a [CoverageStats] entity.
|
||||
/// Delegates to [CoverageRepositoryInterface] and returns a [CoverageStats] entity.
|
||||
class GetCoverageStatsUseCase
|
||||
implements UseCase<GetCoverageStatsArguments, CoverageStats> {
|
||||
/// Creates a [GetCoverageStatsUseCase].
|
||||
GetCoverageStatsUseCase(this._repository);
|
||||
|
||||
final CoverageRepository _repository;
|
||||
final CoverageRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<CoverageStats> call(GetCoverageStatsArguments arguments) {
|
||||
|
||||
@@ -2,17 +2,17 @@ import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:client_coverage/src/domain/arguments/get_shifts_for_date_arguments.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart';
|
||||
|
||||
/// Use case for fetching shifts with workers for a specific date.
|
||||
///
|
||||
/// Delegates to [CoverageRepository] and returns V2 [ShiftWithWorkers] entities.
|
||||
/// Delegates to [CoverageRepositoryInterface] and returns V2 [ShiftWithWorkers] entities.
|
||||
class GetShiftsForDateUseCase
|
||||
implements UseCase<GetShiftsForDateArguments, List<ShiftWithWorkers>> {
|
||||
/// Creates a [GetShiftsForDateUseCase].
|
||||
GetShiftsForDateUseCase(this._repository);
|
||||
|
||||
final CoverageRepository _repository;
|
||||
final CoverageRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<List<ShiftWithWorkers>> call(GetShiftsForDateArguments arguments) {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'package:client_coverage/src/domain/arguments/submit_worker_review_arguments.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart';
|
||||
|
||||
/// Use case for submitting a worker review from the coverage page.
|
||||
///
|
||||
/// Validates the rating range and delegates to [CoverageRepository].
|
||||
/// Validates the rating range and delegates to [CoverageRepositoryInterface].
|
||||
class SubmitWorkerReviewUseCase
|
||||
implements UseCase<SubmitWorkerReviewArguments, void> {
|
||||
/// Creates a [SubmitWorkerReviewUseCase].
|
||||
SubmitWorkerReviewUseCase(this._repository);
|
||||
|
||||
final CoverageRepository _repository;
|
||||
final CoverageRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(SubmitWorkerReviewArguments arguments) async {
|
||||
|
||||
@@ -10,14 +10,14 @@ import 'package:client_coverage/src/presentation/blocs/coverage_event.dart';
|
||||
import 'package:client_coverage/src/presentation/blocs/coverage_state.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/coverage_calendar_selector.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/coverage_page_skeleton.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/coverage_quick_stats.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/coverage_shift_list.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/coverage_stats_header.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/late_workers_alert.dart';
|
||||
|
||||
/// Page for displaying daily coverage information.
|
||||
///
|
||||
/// Shows shifts, worker statuses, and coverage statistics for a selected date.
|
||||
/// Shows shifts, worker statuses, and coverage statistics for a selected date
|
||||
/// using a collapsible SliverAppBar with gradient header and live activity feed.
|
||||
class CoveragePage extends StatefulWidget {
|
||||
/// Creates a [CoveragePage].
|
||||
const CoveragePage({super.key});
|
||||
@@ -27,14 +27,13 @@ class CoveragePage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _CoveragePageState extends State<CoveragePage> {
|
||||
/// Controller for the [CustomScrollView].
|
||||
late ScrollController _scrollController;
|
||||
bool _isScrolled = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController = ScrollController();
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -43,16 +42,6 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.hasClients) {
|
||||
if (_scrollController.offset > 180 && !_isScrolled) {
|
||||
setState(() => _isScrolled = true);
|
||||
} else if (_scrollController.offset <= 180 && _isScrolled) {
|
||||
setState(() => _isScrolled = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<CoverageBloc>(
|
||||
@@ -69,6 +58,21 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
type: UiSnackbarType.error,
|
||||
);
|
||||
}
|
||||
if (state.writeStatus == CoverageWriteStatus.submitted) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: context.t.client_coverage.review.success,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
}
|
||||
if (state.writeStatus == CoverageWriteStatus.submitFailure &&
|
||||
state.writeErrorMessage != null) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: translateErrorKey(state.writeErrorMessage!),
|
||||
type: UiSnackbarType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (BuildContext context, CoverageState state) {
|
||||
final DateTime selectedDate = state.selectedDate ?? DateTime.now();
|
||||
@@ -78,19 +82,26 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
slivers: <Widget>[
|
||||
SliverAppBar(
|
||||
pinned: true,
|
||||
expandedHeight: 300.0,
|
||||
expandedHeight: 316.0,
|
||||
backgroundColor: UiColors.primary,
|
||||
title: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Text(
|
||||
_isScrolled
|
||||
? DateFormat('MMMM d').format(selectedDate)
|
||||
: context.t.client_coverage.page.daily_coverage,
|
||||
key: ValueKey<bool>(_isScrolled),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
context.t.client_coverage.page.daily_coverage,
|
||||
style: UiTypography.title2m.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
DateFormat('EEEE, MMMM d').format(selectedDate),
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.primaryForeground
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
@@ -117,10 +128,13 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
],
|
||||
flexibleSpace: Container(
|
||||
decoration: const BoxDecoration(
|
||||
// Intentional gradient: the second stop is a darker
|
||||
// variant of UiColors.primary used only for the
|
||||
// coverage header visual effect.
|
||||
gradient: LinearGradient(
|
||||
colors: <Color>[
|
||||
UiColors.primary,
|
||||
UiColors.primary,
|
||||
Color(0xFF0626A8),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
@@ -154,6 +168,12 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
state.stats?.totalPositionsConfirmed ?? 0,
|
||||
totalNeeded:
|
||||
state.stats?.totalPositionsNeeded ?? 0,
|
||||
totalCheckedIn:
|
||||
state.stats?.totalWorkersCheckedIn ?? 0,
|
||||
totalEnRoute:
|
||||
state.stats?.totalWorkersEnRoute ?? 0,
|
||||
totalLate:
|
||||
state.stats?.totalWorkersLate ?? 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -176,7 +196,10 @@ class _CoveragePageState extends State<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({
|
||||
required BuildContext context,
|
||||
required CoverageState state,
|
||||
@@ -226,9 +249,6 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space6,
|
||||
children: <Widget>[
|
||||
Column(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
if (state.stats != null &&
|
||||
state.stats!.totalWorkersLate > 0) ...<Widget>[
|
||||
@@ -236,15 +256,13 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
lateCount: state.stats!.totalWorkersLate,
|
||||
),
|
||||
],
|
||||
if (state.stats != null) ...<Widget>[
|
||||
CoverageQuickStats(stats: state.stats!),
|
||||
],
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'${context.t.client_coverage.page.shifts} (${state.shifts.length})',
|
||||
style: UiTypography.title2b.copyWith(
|
||||
color: UiColors.textPrimary,
|
||||
context.t.client_coverage.page.live_activity,
|
||||
style: UiTypography.body4m.copyWith(
|
||||
color: UiColors.textSecondary,
|
||||
letterSpacing: 2.0,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
CoverageShiftList(shifts: state.shifts),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? UiColors.primaryForeground
|
||||
: UiColors.primaryForeground.withOpacity(0.1),
|
||||
: UiColors.primaryForeground.withAlpha(25),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: isToday && !isSelected
|
||||
? Border.all(
|
||||
@@ -122,6 +122,14 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
DateFormat('E').format(date),
|
||||
style: UiTypography.body4m.copyWith(
|
||||
color: isSelected
|
||||
? UiColors.primary
|
||||
: UiColors.primaryForeground.withAlpha(179),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
date.day.toString().padLeft(2, '0'),
|
||||
style: UiTypography.body1b.copyWith(
|
||||
@@ -130,14 +138,6 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
|
||||
: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
DateFormat('E').format(date),
|
||||
style: UiTypography.body4m.copyWith(
|
||||
color: isSelected
|
||||
? UiColors.mutedForeground
|
||||
: UiColors.primaryForeground.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,40 +5,30 @@ import 'package:client_coverage/src/presentation/widgets/coverage_page_skeleton/
|
||||
|
||||
/// Shimmer loading skeleton that mimics the coverage page loaded layout.
|
||||
///
|
||||
/// Shows placeholder shapes for the quick stats row, shift section header,
|
||||
/// and a list of shift cards with worker rows.
|
||||
/// Shows placeholder shapes for the live activity section label and a list
|
||||
/// of shift cards with worker rows.
|
||||
class CoveragePageSkeleton extends StatelessWidget {
|
||||
/// Creates a [CoveragePageSkeleton].
|
||||
const CoveragePageSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UiShimmer(
|
||||
return const UiShimmer(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
padding: EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Quick stats row (2 stat cards)
|
||||
const Row(
|
||||
children: [
|
||||
Expanded(child: UiShimmerStatsCard()),
|
||||
SizedBox(width: UiConstants.space2),
|
||||
Expanded(child: UiShimmerStatsCard()),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Shifts section header
|
||||
const UiShimmerLine(width: 140, height: 18),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
children: <Widget>[
|
||||
// "LIVE ACTIVITY" section label placeholder
|
||||
UiShimmerLine(width: 100, height: 10),
|
||||
SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Shift cards with worker rows
|
||||
const ShiftCardSkeleton(),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
const ShiftCardSkeleton(),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
const ShiftCardSkeleton(),
|
||||
ShiftCardSkeleton(),
|
||||
SizedBox(height: UiConstants.space3),
|
||||
ShiftCardSkeleton(),
|
||||
SizedBox(height: UiConstants.space3),
|
||||
ShiftCardSkeleton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -15,19 +15,19 @@ class ShiftCardSkeleton extends StatelessWidget {
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
// Shift header
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
const UiShimmerLine(width: 180, height: 16),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
const UiShimmerLine(width: 120, height: 12),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Row(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
const UiShimmerLine(width: 80, height: 12),
|
||||
const Spacer(),
|
||||
UiShimmerBox(
|
||||
@@ -47,7 +47,7 @@ class ShiftCardSkeleton extends StatelessWidget {
|
||||
horizontal: UiConstants.space3,
|
||||
).copyWith(bottom: UiConstants.space3),
|
||||
child: const Column(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
UiShimmerListItem(),
|
||||
UiShimmerListItem(),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,17 @@ import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:client_coverage/src/presentation/widgets/cancel_late_worker_sheet.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/shift_header.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/worker_row.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/worker_review_sheet.dart';
|
||||
|
||||
/// List of shifts with their workers.
|
||||
/// Displays a list of shifts as collapsible cards with worker details.
|
||||
///
|
||||
/// Displays all shifts for the selected date, or an empty state if none exist.
|
||||
class CoverageShiftList extends StatelessWidget {
|
||||
/// Each shift is rendered as a card with a tappable [ShiftHeader] that toggles
|
||||
/// visibility of the worker rows beneath it. All cards start expanded.
|
||||
/// Shows an empty state when [shifts] is empty.
|
||||
class CoverageShiftList extends StatefulWidget {
|
||||
/// Creates a [CoverageShiftList].
|
||||
const CoverageShiftList({
|
||||
required this.shifts,
|
||||
@@ -20,17 +24,73 @@ class CoverageShiftList extends StatelessWidget {
|
||||
/// The list of shifts to display.
|
||||
final List<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).
|
||||
String _formatTime(DateTime? time) {
|
||||
if (time == null) return '';
|
||||
return DateFormat('h:mm a').format(time);
|
||||
}
|
||||
|
||||
/// Toggles the expanded / collapsed state for the shift with [shiftId].
|
||||
void _toggleShift(String shiftId) {
|
||||
setState(() {
|
||||
if (_expandedShiftIds.contains(shiftId)) {
|
||||
_expandedShiftIds.remove(shiftId);
|
||||
} else {
|
||||
_expandedShiftIds.add(shiftId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Seeds [_expandedShiftIds] with all current shift IDs on first build,
|
||||
/// and adds any new shift IDs when the widget is rebuilt with new data.
|
||||
void _ensureInitialised() {
|
||||
if (!_initialised) {
|
||||
_expandedShiftIds.addAll(
|
||||
widget.shifts.map((ShiftWithWorkers s) => s.shiftId),
|
||||
);
|
||||
_initialised = true;
|
||||
return;
|
||||
}
|
||||
// Add any new shift IDs that arrived after initial build.
|
||||
for (final ShiftWithWorkers shift in widget.shifts) {
|
||||
if (!_expandedShiftIds.contains(shift.shiftId)) {
|
||||
_expandedShiftIds.add(shift.shiftId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant CoverageShiftList oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Add newly-appeared shift IDs so they start expanded.
|
||||
for (final ShiftWithWorkers shift in widget.shifts) {
|
||||
if (!oldWidget.shifts.any(
|
||||
(ShiftWithWorkers old) => old.shiftId == shift.shiftId,
|
||||
)) {
|
||||
_expandedShiftIds.add(shift.shiftId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_ensureInitialised();
|
||||
|
||||
final TranslationsClientCoverageEn l10n = context.t.client_coverage;
|
||||
|
||||
if (shifts.isEmpty) {
|
||||
if (widget.shifts.isEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space8),
|
||||
width: double.infinity,
|
||||
@@ -57,53 +117,85 @@ class CoverageShiftList extends StatelessWidget {
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: shifts.map((ShiftWithWorkers shift) {
|
||||
children: widget.shifts.map((ShiftWithWorkers shift) {
|
||||
final int coveragePercent = shift.requiredWorkerCount > 0
|
||||
? ((shift.assignedWorkerCount / shift.requiredWorkerCount) * 100)
|
||||
.round()
|
||||
: 0;
|
||||
|
||||
// Per-shift worker status counts.
|
||||
final int onSite = shift.assignedWorkers
|
||||
.where(
|
||||
(AssignedWorker w) => w.status == AssignmentStatus.checkedIn,
|
||||
)
|
||||
.length;
|
||||
final int enRoute = shift.assignedWorkers
|
||||
.where(
|
||||
(AssignedWorker w) =>
|
||||
w.status == AssignmentStatus.accepted && w.checkInAt == null,
|
||||
)
|
||||
.length;
|
||||
final int lateCount = shift.assignedWorkers
|
||||
.where(
|
||||
(AssignedWorker w) => w.status == AssignmentStatus.noShow,
|
||||
)
|
||||
.length;
|
||||
|
||||
final bool isExpanded = _expandedShiftIds.contains(shift.shiftId);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.bgPopup,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border),
|
||||
borderRadius: UiConstants.radius2xl,
|
||||
boxShadow: <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
ShiftHeader(
|
||||
title: shift.roleName,
|
||||
location: '', // V2 API does not return location on coverage
|
||||
startTime: _formatTime(shift.timeRange.startsAt),
|
||||
current: shift.assignedWorkerCount,
|
||||
total: shift.requiredWorkerCount,
|
||||
coveragePercent: coveragePercent,
|
||||
shiftId: shift.shiftId,
|
||||
onSiteCount: onSite,
|
||||
enRouteCount: enRoute,
|
||||
lateCount: lateCount,
|
||||
isExpanded: isExpanded,
|
||||
onToggle: () => _toggleShift(shift.shiftId),
|
||||
),
|
||||
if (shift.assignedWorkers.isNotEmpty)
|
||||
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,
|
||||
AnimatedCrossFade(
|
||||
firstChild: const SizedBox.shrink(),
|
||||
secondChild: _buildWorkerSection(shift, l10n),
|
||||
crossFadeState: isExpanded
|
||||
? CrossFadeState.showSecond
|
||||
: CrossFadeState.showFirst,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
),
|
||||
child: WorkerRow(
|
||||
worker: worker,
|
||||
shiftStartTime:
|
||||
_formatTime(shift.timeRange.startsAt),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).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: const EdgeInsets.all(UiConstants.space4),
|
||||
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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,43 +2,77 @@ import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Displays coverage percentage and worker ratio in the app bar header.
|
||||
/// Displays overall coverage statistics in the SliverAppBar expanded header.
|
||||
///
|
||||
/// Shows the coverage percentage, a progress bar, and real-time worker
|
||||
/// status counts (on site, en route, late) on a primary blue gradient
|
||||
/// background with a semi-transparent white container.
|
||||
class CoverageStatsHeader extends StatelessWidget {
|
||||
/// Creates a [CoverageStatsHeader].
|
||||
/// Creates a [CoverageStatsHeader] with coverage and worker status data.
|
||||
const CoverageStatsHeader({
|
||||
required this.coveragePercent,
|
||||
required this.totalConfirmed,
|
||||
required this.totalNeeded,
|
||||
required this.totalCheckedIn,
|
||||
required this.totalEnRoute,
|
||||
required this.totalLate,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The current coverage percentage.
|
||||
/// The current overall coverage percentage (0-100).
|
||||
final double coveragePercent;
|
||||
|
||||
/// The number of confirmed workers.
|
||||
final int totalConfirmed;
|
||||
|
||||
/// The total number of workers needed.
|
||||
/// The total number of workers needed for full coverage.
|
||||
final int totalNeeded;
|
||||
|
||||
/// The number of workers currently checked in and on site.
|
||||
final int totalCheckedIn;
|
||||
|
||||
/// The number of workers currently en route.
|
||||
final int totalEnRoute;
|
||||
|
||||
/// The number of workers currently marked as late.
|
||||
final int totalLate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primaryForeground.withOpacity(0.1),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
color: UiColors.primaryForeground.withValues(alpha: 0.12),
|
||||
borderRadius: UiConstants.radiusXl,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <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,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
context.t.client_coverage.page.coverage_status,
|
||||
style: UiTypography.body2r.copyWith(
|
||||
color: UiColors.primaryForeground.withOpacity(0.7),
|
||||
context.t.client_coverage.page.overall_coverage,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.primaryForeground.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
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,
|
||||
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>[
|
||||
Text(
|
||||
context.t.client_coverage.page.workers,
|
||||
style: UiTypography.body2r.copyWith(
|
||||
color: UiColors.primaryForeground.withOpacity(0.7),
|
||||
value.toString(),
|
||||
style: UiTypography.title2b.copyWith(
|
||||
color: valueColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
'$totalConfirmed/$totalNeeded',
|
||||
style: UiTypography.title2m.copyWith(
|
||||
label,
|
||||
style: UiTypography.body4m.copyWith(
|
||||
color: UiColors.primaryForeground.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the horizontal progress bar indicating coverage fill.
|
||||
Widget _buildProgressBar() {
|
||||
final double clampedFraction =
|
||||
(coveragePercent / 100).clamp(0.0, 1.0);
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
child: SizedBox(
|
||||
height: 8,
|
||||
width: double.infinity,
|
||||
child: Stack(
|
||||
children: <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,
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,38 +2,54 @@ import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Alert widget for displaying late workers warning.
|
||||
/// Alert banner displayed when workers are running late.
|
||||
///
|
||||
/// Shows a warning banner when workers are running late.
|
||||
/// Renders a solid red container with a warning icon, late worker count,
|
||||
/// and auto-backup status message in white text.
|
||||
class LateWorkersAlert extends StatelessWidget {
|
||||
/// Creates a [LateWorkersAlert].
|
||||
/// Creates a [LateWorkersAlert] with the given [lateCount].
|
||||
const LateWorkersAlert({
|
||||
required this.lateCount,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The number of late workers.
|
||||
/// The number of workers currently marked as late.
|
||||
final int lateCount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.destructive.withValues(alpha: 0.1),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(
|
||||
color: UiColors.destructive,
|
||||
width: 0.5,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
vertical: UiConstants.space3,
|
||||
),
|
||||
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(
|
||||
spacing: UiConstants.space4,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.warning,
|
||||
color: UiColors.destructive,
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.warning,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -41,12 +57,14 @@ class LateWorkersAlert extends StatelessWidget {
|
||||
Text(
|
||||
context.t.client_coverage.alert
|
||||
.workers_running_late(n: lateCount, count: lateCount),
|
||||
style: UiTypography.body1b.textError,
|
||||
style: UiTypography.body1b.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.t.client_coverage.alert.auto_backup_searching,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.textError.withValues(alpha: 0.7),
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,124 +1,198 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:client_coverage/src/presentation/widgets/coverage_badge.dart';
|
||||
|
||||
/// Header section for a shift card showing title, location, time, and coverage.
|
||||
/// Tappable header for a collapsible shift card.
|
||||
///
|
||||
/// Displays a status dot colour-coded by coverage, the shift title and time,
|
||||
/// a filled/total badge, a linear progress bar, and per-shift worker summary
|
||||
/// counts (on site, en route, late). Tapping anywhere triggers [onToggle].
|
||||
class ShiftHeader extends StatelessWidget {
|
||||
/// Creates a [ShiftHeader].
|
||||
const ShiftHeader({
|
||||
required this.title,
|
||||
required this.location,
|
||||
required this.startTime,
|
||||
required this.current,
|
||||
required this.total,
|
||||
required this.coveragePercent,
|
||||
required this.shiftId,
|
||||
required this.onSiteCount,
|
||||
required this.enRouteCount,
|
||||
required this.lateCount,
|
||||
required this.isExpanded,
|
||||
required this.onToggle,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The shift title.
|
||||
/// The shift role or title.
|
||||
final String title;
|
||||
|
||||
/// The shift location.
|
||||
final String location;
|
||||
|
||||
/// The formatted shift start time.
|
||||
/// Formatted shift start time (e.g. "8:00 AM").
|
||||
final String startTime;
|
||||
|
||||
/// Current number of assigned workers.
|
||||
final int current;
|
||||
|
||||
/// Total workers needed for the shift.
|
||||
/// Total workers required for the shift.
|
||||
final int total;
|
||||
|
||||
/// Coverage percentage (0-100+).
|
||||
final int coveragePercent;
|
||||
|
||||
/// The shift identifier.
|
||||
/// Unique shift identifier.
|
||||
final String shiftId;
|
||||
|
||||
/// Number of workers currently on site (checked in).
|
||||
final int onSiteCount;
|
||||
|
||||
/// Number of workers en route (accepted but not checked in).
|
||||
final int enRouteCount;
|
||||
|
||||
/// Number of workers marked as late / no-show.
|
||||
final int lateCount;
|
||||
|
||||
/// Whether the shift card is currently expanded to show workers.
|
||||
final bool isExpanded;
|
||||
|
||||
/// Callback invoked when the header is tapped to expand or collapse.
|
||||
final VoidCallback onToggle;
|
||||
|
||||
/// Returns the status colour based on [coveragePercent].
|
||||
///
|
||||
/// Green for >= 100 %, yellow for >= 80 %, red otherwise.
|
||||
Color _statusColor() {
|
||||
if (coveragePercent >= 100) {
|
||||
return UiColors.textSuccess;
|
||||
} else if (coveragePercent >= 80) {
|
||||
return UiColors.textWarning;
|
||||
}
|
||||
return UiColors.destructive;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
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),
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.muted,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: UiColors.border,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
spacing: UiConstants.space4,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
// Row 1: status dot, title + time, badge, chevron.
|
||||
Row(
|
||||
spacing: UiConstants.space2,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: UiConstants.space2,
|
||||
height: UiConstants.space2,
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.primary,
|
||||
// Status dot.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: UiConstants.space1),
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space4),
|
||||
// Title and start time.
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.body1b.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
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>[
|
||||
const Icon(
|
||||
UiIcons.clock,
|
||||
size: UiConstants.space3,
|
||||
color: UiColors.iconSecondary,
|
||||
size: 10,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
startTime,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Coverage badge.
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2,
|
||||
vertical: UiConstants.space1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withAlpha(26),
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
),
|
||||
child: Text(
|
||||
'$current/$total',
|
||||
style: UiTypography.body3b.copyWith(color: statusColor),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
// Expand / collapse chevron.
|
||||
Icon(
|
||||
isExpanded ? UiIcons.chevronUp : UiIcons.chevronDown,
|
||||
size: 16,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
CoverageBadge(
|
||||
current: current,
|
||||
total: total,
|
||||
coveragePercent: coveragePercent,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ class WorkerRow extends StatelessWidget {
|
||||
const WorkerRow({
|
||||
required this.worker,
|
||||
required this.shiftStartTime,
|
||||
this.showRateButton = false,
|
||||
this.showCancelButton = false,
|
||||
this.onRate,
|
||||
this.onCancel,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -19,6 +23,18 @@ class WorkerRow extends StatelessWidget {
|
||||
/// The formatted shift start time.
|
||||
final String shiftStartTime;
|
||||
|
||||
/// Whether to show the rate action button.
|
||||
final bool showRateButton;
|
||||
|
||||
/// Whether to show the cancel action button.
|
||||
final bool showCancelButton;
|
||||
|
||||
/// Callback invoked when the rate button is tapped.
|
||||
final VoidCallback? onRate;
|
||||
|
||||
/// Callback invoked when the cancel button is tapped.
|
||||
final VoidCallback? onCancel;
|
||||
|
||||
/// Formats a [DateTime] to a readable time string (h:mm a).
|
||||
String _formatCheckInTime(DateTime? time) {
|
||||
if (time == null) return '';
|
||||
@@ -35,10 +51,6 @@ class WorkerRow extends StatelessWidget {
|
||||
Color textColor;
|
||||
IconData icon;
|
||||
String statusText;
|
||||
Color badgeBg;
|
||||
Color badgeText;
|
||||
Color badgeBorder;
|
||||
String badgeLabel;
|
||||
|
||||
switch (worker.status) {
|
||||
case AssignmentStatus.checkedIn:
|
||||
@@ -50,10 +62,6 @@ class WorkerRow extends StatelessWidget {
|
||||
statusText = l10n.status_checked_in_at(
|
||||
time: _formatCheckInTime(worker.checkInAt),
|
||||
);
|
||||
badgeBg = UiColors.textSuccess.withAlpha(40);
|
||||
badgeText = UiColors.textSuccess;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_on_site;
|
||||
case AssignmentStatus.accepted:
|
||||
if (worker.checkInAt == null) {
|
||||
bg = UiColors.textWarning.withAlpha(26);
|
||||
@@ -62,10 +70,6 @@ class WorkerRow extends StatelessWidget {
|
||||
textColor = UiColors.textWarning;
|
||||
icon = UiIcons.clock;
|
||||
statusText = l10n.status_en_route_expected(time: shiftStartTime);
|
||||
badgeBg = UiColors.textWarning.withAlpha(40);
|
||||
badgeText = UiColors.textWarning;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_en_route;
|
||||
} else {
|
||||
bg = UiColors.muted.withAlpha(26);
|
||||
border = UiColors.border;
|
||||
@@ -73,10 +77,6 @@ class WorkerRow extends StatelessWidget {
|
||||
textColor = UiColors.textSecondary;
|
||||
icon = UiIcons.success;
|
||||
statusText = l10n.status_confirmed;
|
||||
badgeBg = UiColors.textSecondary.withAlpha(40);
|
||||
badgeText = UiColors.textSecondary;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_confirmed;
|
||||
}
|
||||
case AssignmentStatus.noShow:
|
||||
bg = UiColors.destructive.withAlpha(26);
|
||||
@@ -85,10 +85,6 @@ class WorkerRow extends StatelessWidget {
|
||||
textColor = UiColors.destructive;
|
||||
icon = UiIcons.warning;
|
||||
statusText = l10n.status_no_show;
|
||||
badgeBg = UiColors.destructive.withAlpha(40);
|
||||
badgeText = UiColors.destructive;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_no_show;
|
||||
case AssignmentStatus.checkedOut:
|
||||
bg = UiColors.muted.withAlpha(26);
|
||||
border = UiColors.border;
|
||||
@@ -96,10 +92,6 @@ class WorkerRow extends StatelessWidget {
|
||||
textColor = UiColors.textSecondary;
|
||||
icon = UiIcons.success;
|
||||
statusText = l10n.status_checked_out;
|
||||
badgeBg = UiColors.textSecondary.withAlpha(40);
|
||||
badgeText = UiColors.textSecondary;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_done;
|
||||
case AssignmentStatus.completed:
|
||||
bg = UiColors.iconSuccess.withAlpha(26);
|
||||
border = UiColors.iconSuccess;
|
||||
@@ -107,10 +99,6 @@ class WorkerRow extends StatelessWidget {
|
||||
textColor = UiColors.textSuccess;
|
||||
icon = UiIcons.success;
|
||||
statusText = l10n.status_completed;
|
||||
badgeBg = UiColors.textSuccess.withAlpha(40);
|
||||
badgeText = UiColors.textSuccess;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_completed;
|
||||
case AssignmentStatus.assigned:
|
||||
case AssignmentStatus.swapRequested:
|
||||
case AssignmentStatus.cancelled:
|
||||
@@ -121,10 +109,6 @@ class WorkerRow extends StatelessWidget {
|
||||
textColor = UiColors.textSecondary;
|
||||
icon = UiIcons.clock;
|
||||
statusText = worker.status.value;
|
||||
badgeBg = UiColors.textSecondary.withAlpha(40);
|
||||
badgeText = UiColors.textSecondary;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = worker.status.value;
|
||||
}
|
||||
|
||||
return Container(
|
||||
@@ -197,21 +181,23 @@ class WorkerRow extends StatelessWidget {
|
||||
Column(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2,
|
||||
vertical: UiConstants.space1 / 2,
|
||||
if (showRateButton && onRate != null)
|
||||
GestureDetector(
|
||||
onTap: onRate,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,20 +1,34 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'client_main_state.dart';
|
||||
import 'package:client_main/src/presentation/blocs/client_main_state.dart';
|
||||
|
||||
class ClientMainCubit extends Cubit<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()) {
|
||||
Modular.to.addListener(_onRouteChanged);
|
||||
_onRouteChanged();
|
||||
}
|
||||
|
||||
/// Routes that should hide the bottom navigation bar.
|
||||
static const List<String> _hideBottomBarPaths = <String>[
|
||||
ClientPaths.completionReview,
|
||||
ClientPaths.awaitingApproval,
|
||||
];
|
||||
|
||||
/// Updates state when the current route changes.
|
||||
///
|
||||
/// Detects the active tab from the route path and determines
|
||||
/// whether the bottom bar should be visible.
|
||||
void _onRouteChanged() {
|
||||
if (isClosed) return;
|
||||
|
||||
final String path = Modular.to.path;
|
||||
int newIndex = state.currentIndex;
|
||||
|
||||
@@ -41,6 +55,9 @@ class ClientMainCubit extends Cubit<ClientMainState> implements Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigates to the tab at [index] via Modular safe navigation.
|
||||
///
|
||||
/// State update happens automatically via [_onRouteChanged].
|
||||
void navigateToTab(int index) {
|
||||
if (index == state.currentIndex) return;
|
||||
|
||||
@@ -61,7 +78,6 @@ class ClientMainCubit extends Cubit<ClientMainState> implements Disposable {
|
||||
Modular.to.toClientReports();
|
||||
break;
|
||||
}
|
||||
// State update will happen via _onRouteChanged
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// State for [ClientMainCubit] representing bottom navigation status.
|
||||
class ClientMainState extends Equatable {
|
||||
/// Creates a [ClientMainState] with the given tab index and bar visibility.
|
||||
const ClientMainState({
|
||||
this.currentIndex = 2, // Default to Home
|
||||
this.showBottomBar = true,
|
||||
});
|
||||
|
||||
/// Index of the currently active bottom navigation tab.
|
||||
final int currentIndex;
|
||||
|
||||
/// Whether the bottom navigation bar should be visible.
|
||||
final bool showBottomBar;
|
||||
|
||||
/// Creates a copy of this state with updated fields.
|
||||
ClientMainState copyWith({int? currentIndex, bool? showBottomBar}) {
|
||||
return ClientMainState(
|
||||
currentIndex: currentIndex ?? this.currentIndex,
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'package:client_home/src/presentation/blocs/client_home_bloc.dart';
|
||||
import 'package:client_home/src/presentation/blocs/client_home_event.dart';
|
||||
import 'package:client_home/src/presentation/blocs/client_home_state.dart';
|
||||
import 'package:client_home/src/presentation/widgets/dashboard_widget_builder.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
/// Widget that displays the home dashboard in edit mode with drag-and-drop support.
|
||||
///
|
||||
/// Allows users to reorder and rearrange dashboard widgets.
|
||||
class ClientHomeEditModeBody extends StatelessWidget {
|
||||
/// Creates a [ClientHomeEditModeBody].
|
||||
const ClientHomeEditModeBody({required this.state, super.key});
|
||||
|
||||
/// The current home state.
|
||||
final ClientHomeState state;
|
||||
|
||||
/// Creates a [ClientHomeEditModeBody].
|
||||
const ClientHomeEditModeBody({
|
||||
required this.state,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ReorderableListView(
|
||||
@@ -30,18 +26,15 @@ class ClientHomeEditModeBody extends StatelessWidget {
|
||||
100,
|
||||
),
|
||||
onReorder: (int oldIndex, int newIndex) {
|
||||
BlocProvider.of<ClientHomeBloc>(context)
|
||||
.add(ClientHomeWidgetReordered(oldIndex, newIndex));
|
||||
BlocProvider.of<ClientHomeBloc>(
|
||||
context,
|
||||
).add(ClientHomeWidgetReordered(oldIndex, newIndex));
|
||||
},
|
||||
children: state.widgetOrder.map((String id) {
|
||||
return Container(
|
||||
key: ValueKey<String>(id),
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||
child: DashboardWidgetBuilder(
|
||||
id: id,
|
||||
state: state,
|
||||
isEditMode: true,
|
||||
),
|
||||
child: DashboardWidgetBuilder(id: id, state: state, isEditMode: true),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
|
||||
@@ -10,9 +10,9 @@ class ClientHomeHeaderSkeleton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UiShimmer(
|
||||
return const UiShimmer(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
UiConstants.space4,
|
||||
UiConstants.space4,
|
||||
UiConstants.space4,
|
||||
@@ -23,11 +23,11 @@ class ClientHomeHeaderSkeleton extends StatelessWidget {
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const UiShimmerCircle(size: UiConstants.space10),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
UiShimmerCircle(size: UiConstants.space10),
|
||||
SizedBox(width: UiConstants.space3),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const <Widget>[
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 80, height: 12),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 120, height: 16),
|
||||
@@ -37,7 +37,7 @@ class ClientHomeHeaderSkeleton extends StatelessWidget {
|
||||
),
|
||||
Row(
|
||||
spacing: UiConstants.space2,
|
||||
children: const <Widget>[
|
||||
children: <Widget>[
|
||||
UiShimmerBox(width: 36, height: 36),
|
||||
UiShimmerBox(width: 36, height: 36),
|
||||
],
|
||||
|
||||
@@ -10,9 +10,9 @@ class ReorderSectionSkeleton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
return const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const <Widget>[
|
||||
children: <Widget>[
|
||||
UiShimmerSectionHeader(),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
SizedBox(
|
||||
|
||||
@@ -20,7 +20,8 @@ dependencies:
|
||||
path: ../../../design_system
|
||||
core_localization:
|
||||
path: ../../../core_localization
|
||||
krow_domain: ^0.0.1
|
||||
krow_domain:
|
||||
path: ../../../domain
|
||||
krow_core:
|
||||
path: ../../../core
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:client_hubs/src/presentation/blocs/edit_hub/edit_hub_bloc.dart';
|
||||
@@ -38,7 +39,7 @@ class EditHubPage extends StatelessWidget {
|
||||
message: message,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
Modular.to.pop(true);
|
||||
Modular.to.popSafe(true);
|
||||
}
|
||||
if (state.status == EditHubStatus.failure &&
|
||||
state.errorMessage != null) {
|
||||
@@ -65,7 +66,7 @@ class EditHubPage extends StatelessWidget {
|
||||
child: HubForm(
|
||||
hub: hub,
|
||||
costCenters: state.costCenters,
|
||||
onCancel: () => Modular.to.pop(),
|
||||
onCancel: () => Modular.to.popSafe(),
|
||||
onSave: ({
|
||||
required String name,
|
||||
required String fullAddress,
|
||||
|
||||
@@ -38,7 +38,7 @@ class HubDetailsPage extends StatelessWidget {
|
||||
message: message,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
Modular.to.pop(true); // Return true to indicate change
|
||||
Modular.to.popSafe(true); // Return true to indicate change
|
||||
}
|
||||
if (state.status == HubDetailsStatus.failure &&
|
||||
state.errorMessage != null) {
|
||||
@@ -117,7 +117,7 @@ class HubDetailsPage extends StatelessWidget {
|
||||
Future<void> _navigateToEditPage(BuildContext context) async {
|
||||
final bool? saved = await Modular.to.toEditHub(hub: hub);
|
||||
if (saved == true && context.mounted) {
|
||||
Modular.to.pop(true); // Return true to indicate change
|
||||
Modular.to.popSafe(true); // Return true to indicate change
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ class _HubFormState extends State<HubForm> {
|
||||
vertical: 16,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFD),
|
||||
color: UiColors.muted,
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusBase * 1.5,
|
||||
),
|
||||
@@ -225,7 +225,7 @@ class _HubFormState extends State<HubForm> {
|
||||
color: UiColors.textSecondary.withValues(alpha: 0.5),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF8FAFD),
|
||||
fillColor: UiColors.muted,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
vertical: 16,
|
||||
|
||||
@@ -13,7 +13,7 @@ class HubsPageSkeleton extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return UiShimmer(
|
||||
child: Column(
|
||||
children: List.generate(5, (int index) {
|
||||
children: List<Widget>.generate(5, (int index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: Container(
|
||||
@@ -23,7 +23,7 @@ class HubsPageSkeleton extends StatelessWidget {
|
||||
),
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Row(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
// Leading icon placeholder
|
||||
UiShimmerBox(
|
||||
width: 52,
|
||||
@@ -35,7 +35,7 @@ class HubsPageSkeleton extends StatelessWidget {
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 160, height: 16),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
UiShimmerLine(width: 200, height: 12),
|
||||
|
||||
@@ -11,7 +11,11 @@ import 'domain/usecases/create_one_time_order_usecase.dart';
|
||||
import 'domain/usecases/create_permanent_order_usecase.dart';
|
||||
import 'domain/usecases/create_rapid_order_usecase.dart';
|
||||
import 'domain/usecases/create_recurring_order_usecase.dart';
|
||||
import 'domain/usecases/get_hubs_usecase.dart';
|
||||
import 'domain/usecases/get_managers_by_hub_usecase.dart';
|
||||
import 'domain/usecases/get_order_details_for_reorder_usecase.dart';
|
||||
import 'domain/usecases/get_roles_by_vendor_usecase.dart';
|
||||
import 'domain/usecases/get_vendors_usecase.dart';
|
||||
import 'domain/usecases/parse_rapid_order_usecase.dart';
|
||||
import 'domain/usecases/transcribe_rapid_order_usecase.dart';
|
||||
import 'presentation/blocs/index.dart';
|
||||
@@ -46,7 +50,7 @@ class ClientCreateOrderModule extends Module {
|
||||
),
|
||||
);
|
||||
|
||||
// UseCases
|
||||
// Command UseCases (order creation)
|
||||
i.addLazySingleton(CreateOneTimeOrderUseCase.new);
|
||||
i.addLazySingleton(CreatePermanentOrderUseCase.new);
|
||||
i.addLazySingleton(CreateRecurringOrderUseCase.new);
|
||||
@@ -55,6 +59,12 @@ class ClientCreateOrderModule extends Module {
|
||||
i.addLazySingleton(ParseRapidOrderTextToOrderUseCase.new);
|
||||
i.addLazySingleton(GetOrderDetailsForReorderUseCase.new);
|
||||
|
||||
// Query UseCases (reference data loading)
|
||||
i.addLazySingleton(GetVendorsUseCase.new);
|
||||
i.addLazySingleton(GetRolesByVendorUseCase.new);
|
||||
i.addLazySingleton(GetHubsUseCase.new);
|
||||
i.addLazySingleton(GetManagersByHubUseCase.new);
|
||||
|
||||
// BLoCs
|
||||
i.add<RapidOrderBloc>(
|
||||
() => RapidOrderBloc(
|
||||
@@ -63,15 +73,36 @@ class ClientCreateOrderModule extends Module {
|
||||
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>(
|
||||
() => PermanentOrderBloc(
|
||||
i.get<CreatePermanentOrderUseCase>(),
|
||||
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
|
||||
|
||||
@@ -1,15 +1,69 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Arguments for the [CreateOneTimeOrderUseCase].
|
||||
///
|
||||
/// Wraps the V2 API payload map for a one-time order.
|
||||
class OneTimeOrderArguments extends UseCaseArgument {
|
||||
/// Creates a [OneTimeOrderArguments] with the given [payload].
|
||||
const OneTimeOrderArguments({required this.payload});
|
||||
/// A single position entry for a one-time order submission.
|
||||
class OneTimeOrderPositionArgument extends UseCaseArgument {
|
||||
/// Creates a [OneTimeOrderPositionArgument].
|
||||
const OneTimeOrderPositionArgument({
|
||||
required this.roleId,
|
||||
required this.workerCount,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
this.roleName,
|
||||
this.lunchBreak,
|
||||
});
|
||||
|
||||
/// The V2 API payload map.
|
||||
final Map<String, dynamic> payload;
|
||||
/// The role ID for this position.
|
||||
final String roleId;
|
||||
|
||||
/// Human-readable role name, if available.
|
||||
final String? roleName;
|
||||
|
||||
/// Number of workers needed for this position.
|
||||
final int workerCount;
|
||||
|
||||
/// Shift start time in HH:mm format.
|
||||
final String startTime;
|
||||
|
||||
/// Shift end time in HH:mm format.
|
||||
final String endTime;
|
||||
|
||||
/// Break duration label (e.g. `'MIN_30'`, `'NO_BREAK'`), if set.
|
||||
final String? lunchBreak;
|
||||
|
||||
@override
|
||||
List<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];
|
||||
}
|
||||
|
||||
@@ -1,10 +1,75 @@
|
||||
/// Arguments for the [CreatePermanentOrderUseCase].
|
||||
///
|
||||
/// Wraps the V2 API payload map for a permanent order.
|
||||
class PermanentOrderArguments {
|
||||
/// Creates a [PermanentOrderArguments] with the given [payload].
|
||||
const PermanentOrderArguments({required this.payload});
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// The V2 API payload map.
|
||||
final Map<String, dynamic> payload;
|
||||
/// A single position entry for a permanent order submission.
|
||||
class PermanentOrderPositionArgument extends UseCaseArgument {
|
||||
/// Creates a [PermanentOrderPositionArgument].
|
||||
const PermanentOrderPositionArgument({
|
||||
required this.roleId,
|
||||
required this.workerCount,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
this.roleName,
|
||||
});
|
||||
|
||||
/// The role ID for this position.
|
||||
final String roleId;
|
||||
|
||||
/// Human-readable role name, if available.
|
||||
final String? roleName;
|
||||
|
||||
/// Number of workers needed for this position.
|
||||
final int workerCount;
|
||||
|
||||
/// Shift start time in HH:mm format.
|
||||
final String startTime;
|
||||
|
||||
/// Shift end time in HH:mm format.
|
||||
final String endTime;
|
||||
|
||||
@override
|
||||
List<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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,10 +1,80 @@
|
||||
/// Arguments for the [CreateRecurringOrderUseCase].
|
||||
///
|
||||
/// Wraps the V2 API payload map for a recurring order.
|
||||
class RecurringOrderArguments {
|
||||
/// Creates a [RecurringOrderArguments] with the given [payload].
|
||||
const RecurringOrderArguments({required this.payload});
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// The V2 API payload map.
|
||||
final Map<String, dynamic> payload;
|
||||
/// A single position entry for a recurring order submission.
|
||||
class RecurringOrderPositionArgument extends UseCaseArgument {
|
||||
/// Creates a [RecurringOrderPositionArgument].
|
||||
const RecurringOrderPositionArgument({
|
||||
required this.roleId,
|
||||
required this.workerCount,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
this.roleName,
|
||||
});
|
||||
|
||||
/// The role ID for this position.
|
||||
final String roleId;
|
||||
|
||||
/// Human-readable role name, if available.
|
||||
final String? roleName;
|
||||
|
||||
/// Number of workers needed for this position.
|
||||
final int workerCount;
|
||||
|
||||
/// Shift start time in HH:mm format.
|
||||
final String startTime;
|
||||
|
||||
/// Shift end time in HH:mm format.
|
||||
final String endTime;
|
||||
|
||||
@override
|
||||
List<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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -5,16 +5,45 @@ import '../repositories/client_create_order_repository_interface.dart';
|
||||
|
||||
/// Use case for creating a one-time staffing order.
|
||||
///
|
||||
/// Delegates the V2 API payload to the repository.
|
||||
/// Builds the V2 API payload from typed [OneTimeOrderArguments] and
|
||||
/// delegates submission to the repository. Payload construction (date
|
||||
/// formatting, position mapping, break-minutes conversion) is business
|
||||
/// logic that belongs here, not in the BLoC.
|
||||
class CreateOneTimeOrderUseCase
|
||||
implements UseCase<OneTimeOrderArguments, void> {
|
||||
/// Creates a [CreateOneTimeOrderUseCase].
|
||||
const CreateOneTimeOrderUseCase(this._repository);
|
||||
|
||||
/// The create-order repository.
|
||||
final ClientCreateOrderRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,61 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../arguments/permanent_order_arguments.dart';
|
||||
import '../repositories/client_create_order_repository_interface.dart';
|
||||
|
||||
/// Day-of-week labels in Sunday-first order, matching the V2 API convention.
|
||||
const List<String> _dayLabels = <String>[
|
||||
'SUN',
|
||||
'MON',
|
||||
'TUE',
|
||||
'WED',
|
||||
'THU',
|
||||
'FRI',
|
||||
'SAT',
|
||||
];
|
||||
|
||||
/// Use case for creating a permanent staffing order.
|
||||
///
|
||||
/// Delegates the V2 API payload to the repository.
|
||||
class CreatePermanentOrderUseCase {
|
||||
/// Builds the V2 API payload from typed [PermanentOrderArguments] and
|
||||
/// delegates submission to the repository. Payload construction (date
|
||||
/// formatting, day-of-week mapping, position mapping) is business
|
||||
/// logic that belongs here, not in the BLoC.
|
||||
class CreatePermanentOrderUseCase
|
||||
implements UseCase<PermanentOrderArguments, void> {
|
||||
/// Creates a [CreatePermanentOrderUseCase].
|
||||
const CreatePermanentOrderUseCase(this._repository);
|
||||
|
||||
/// The create-order repository.
|
||||
final ClientCreateOrderRepositoryInterface _repository;
|
||||
|
||||
/// Executes the use case with the given [args].
|
||||
Future<void> call(PermanentOrderArguments args) {
|
||||
return _repository.createPermanentOrder(args.payload);
|
||||
@override
|
||||
Future<void> call(PermanentOrderArguments input) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,63 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../arguments/recurring_order_arguments.dart';
|
||||
import '../repositories/client_create_order_repository_interface.dart';
|
||||
|
||||
/// Day-of-week labels in Sunday-first order, matching the V2 API convention.
|
||||
const List<String> _dayLabels = <String>[
|
||||
'SUN',
|
||||
'MON',
|
||||
'TUE',
|
||||
'WED',
|
||||
'THU',
|
||||
'FRI',
|
||||
'SAT',
|
||||
];
|
||||
|
||||
/// Use case for creating a recurring staffing order.
|
||||
///
|
||||
/// Delegates the V2 API payload to the repository.
|
||||
class CreateRecurringOrderUseCase {
|
||||
/// Builds the V2 API payload from typed [RecurringOrderArguments] and
|
||||
/// delegates submission to the repository. Payload construction (date
|
||||
/// formatting, recurrence-day mapping, position mapping) is business
|
||||
/// logic that belongs here, not in the BLoC.
|
||||
class CreateRecurringOrderUseCase
|
||||
implements UseCase<RecurringOrderArguments, void> {
|
||||
/// Creates a [CreateRecurringOrderUseCase].
|
||||
const CreateRecurringOrderUseCase(this._repository);
|
||||
|
||||
/// The create-order repository.
|
||||
final ClientCreateOrderRepositoryInterface _repository;
|
||||
|
||||
/// Executes the use case with the given [args].
|
||||
Future<void> call(RecurringOrderArguments args) {
|
||||
return _repository.createRecurringOrder(args.payload);
|
||||
@override
|
||||
Future<void> call(RecurringOrderArguments input) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user