diff --git a/.agent/skills/krow-mobile-architecture/SKILL.md b/.agent/skills/krow-mobile-architecture/SKILL.md
index eccc0bb2..febe1686 100644
--- a/.agent/skills/krow-mobile-architecture/SKILL.md
+++ b/.agent/skills/krow-mobile-architecture/SKILL.md
@@ -511,7 +511,7 @@ Modular.to.popSafe();
// ❌ AVOID
Modular.to.navigate('/home'); // No safety
-Navigator.push(...); // No Modular integration
+Navigator.push(...); // No Modular integration (except when popping a dialog).
```
### Data Sharing Patterns
diff --git a/.claude/agent-memory/mobile-qa-analyst/MEMORY.md b/.claude/agent-memory/mobile-qa-analyst/MEMORY.md
new file mode 100644
index 00000000..9bfe7a71
--- /dev/null
+++ b/.claude/agent-memory/mobile-qa-analyst/MEMORY.md
@@ -0,0 +1,4 @@
+# Mobile QA Analyst Memory Index
+
+## Project Context
+- [project_clock_in_feature_issues.md](project_clock_in_feature_issues.md) — Critical bugs in staff clock_in feature: BLoC lifecycle leak, stale geofence override, dead lunch break data, non-functional date selector
diff --git a/.claude/agents/architecture-reviewer.md b/.claude/agents/mobile-architecture-reviewer.md
similarity index 99%
rename from .claude/agents/architecture-reviewer.md
rename to .claude/agents/mobile-architecture-reviewer.md
index ebbffb75..c0c7b2a4 100644
--- a/.claude/agents/architecture-reviewer.md
+++ b/.claude/agents/mobile-architecture-reviewer.md
@@ -54,7 +54,7 @@ and load any additional skills as needed for specific review challenges.
2. Standalone custom `TextStyle(...)` — must use design system typography
3. Hardcoded spacing values — must use design system spacing constants
4. Direct icon library imports — must use design system icon abstractions
-5. Direct `Navigator.push/pop/replace` usage — must use safe navigation extensions
+5. Direct `Navigator.push/pop/replace` usage — must use safe navigation extensions from the `apps/mobile/packages/core/lib/src/routing/navigation_extensions.dart`.
6. Missing tests for use cases or repositories
7. Complex BLoC without bloc_test coverage
8. Test coverage below 70% for business logic
diff --git a/.claude/agents/mobile-builder.md b/.claude/agents/mobile-builder.md
index adb14d7f..3d9009e0 100644
--- a/.claude/agents/mobile-builder.md
+++ b/.claude/agents/mobile-builder.md
@@ -47,6 +47,8 @@ If any of these files are missing or unreadable, notify the user before proceedi
- Skip tests for business logic
### ALWAYS:
+- **Use `package:` imports everywhere inside `lib/`** for consistency and robustness. Use relative imports only in `test/` and `bin/` directories. Example: `import 'package:staff_clock_in/src/presentation/bloc/clock_in/clock_in_bloc.dart';` not `import '../bloc/clock_in/clock_in_bloc.dart';`
+- Place reusable utility functions (math, geo, formatting, etc.) in `apps/mobile/packages/core/lib/src/utils/` and export from `core.dart` — never keep them as private methods in feature packages
- Use feature-first packaging: `domain/`, `data/`, `presentation/`
- Export public API via barrel files
- Use BLoC with `SessionHandlerMixin` for complex state
@@ -55,6 +57,15 @@ If any of these files are missing or unreadable, notify the user before proceedi
- Use `UiColors`, `UiTypography`, `UiIcons`, `UiConstants` for all design values
- Use `core_localization` for user-facing strings
- Add concise `///` doc comments to every class, method, and field. Keep them short (1-2 lines) — just enough for another developer to understand the purpose without reading the implementation.
+- **Always specify explicit types** on every local variable, loop variable, and lambda parameter — never use `final x = ...` or `var x = ...` without the type. Example: `final String name = getName();` not `final name = getName();`. This is enforced by the `always_specify_types` lint rule.
+- **Always place constructors before fields and methods** in class declarations. The correct order is: constructor → fields → methods. This is enforced by the `sort_constructors_first` lint rule. Example:
+ ```dart
+ class MyClass {
+ const MyClass({required this.name});
+ final String name;
+ void doSomething() {}
+ }
+ ```
## Standard Workflow
@@ -120,13 +131,19 @@ features/
entities/ # Pure Dart classes
repositories/ # Abstract interfaces
usecases/ # Business logic lives HERE
+ validators/ # Composable validation pipeline (optional)
domain.dart # Barrel file
data/
models/ # With fromJson/toJson
repositories/ # Concrete implementations
data.dart # Barrel file
presentation/
- bloc/ # Events, states, BLoC
+ bloc/
+ feature_bloc/ # Each BLoC in its own subfolder
+ feature_bloc.dart
+ feature_event.dart
+ feature_state.dart
+ strategies/ # Strategy pattern implementations (optional)
screens/ # Full pages
widgets/ # Reusable components
presentation.dart # Barrel file
diff --git a/.claude/agents/mobile-qa-analyst.md b/.claude/agents/mobile-qa-analyst.md
new file mode 100644
index 00000000..f21ca49b
--- /dev/null
+++ b/.claude/agents/mobile-qa-analyst.md
@@ -0,0 +1,355 @@
+---
+name: mobile-qa-analyst
+description: "Use this agent when you need to identify potential bugs, edge cases, and issues in Flutter mobile code through static analysis. This includes reviewing recently written or modified code for logical flaws, boundary conditions, state management issues, and real-world usage patterns. Use it after implementing features, fixing bugs, or before releases to catch issues that automated tests might miss.\\n\\nExamples:\\n\\n\\nContext: The user just implemented a new checkout flow feature.\\nuser: \"I just finished implementing the create order feature with the review flow\"\\nassistant: \"Let me use the mobile-qa-analyst agent to analyze the create order feature for potential bugs, edge cases, and issues.\"\\n\\nSince a significant feature was just implemented, use the Agent tool to launch the mobile-qa-analyst agent to perform a thorough QA analysis of the new code for edge cases, state management issues, and potential bugs.\\n\\n\\n\\n\\nContext: The user wants to check their BLoC implementation for issues before merging.\\nuser: \"Can you review my new authentication BLoC for potential issues?\"\\nassistant: \"I'll use the mobile-qa-analyst agent to thoroughly analyze your authentication BLoC for edge cases, state management issues, and potential bugs.\"\\n\\nSince the user wants a quality review of their BLoC implementation, use the Agent tool to launch the mobile-qa-analyst agent to identify potential issues from a manual QA perspective.\\n\\n\\n\\n\\nContext: The user modified network handling code and wants to ensure robustness.\\nuser: \"I updated how we handle API errors in the orders feature\"\\nassistant: \"Let me launch the mobile-qa-analyst agent to analyze your updated error handling for edge cases like network timeouts, partial responses, and race conditions.\"\\n\\nSince error handling code was modified, proactively use the Agent tool to launch the mobile-qa-analyst agent to verify robustness against various failure scenarios.\\n\\n"
+model: opus
+color: pink
+memory: project
+---
+
+You are an expert **Manual QA Engineer specializing in Flutter mobile applications** within the KROW Workforce platform. Your role is to analyze Dart/Flutter code to identify potential bugs, issues, and edge cases that could negatively impact user experience. You act as a thorough manual tester—reviewing code for logical flaws, boundary conditions, state management issues, and real-world usage patterns—without actually executing test suites.
+
+## Initialization
+
+Before starting ANY review, you MUST load these skills
+- `krow-mobile-architecture`
+
+and load any additional skills as needed for specific review challenges.
+
+## Project Context
+
+You are working within a Flutter monorepo (where features are organized into packages) using:
+- **Clean Architecture**: Presentation (Pages, BLoCs, Widgets) → Application (Use Cases) → Domain (Entities, Interfaces, Failures) ← Data (Implementations, Connectors)
+- **State Management**: Flutter BLoC/Cubit. BLoCs registered with `i.add()` (transient), never `i.addSingleton()`. `BlocProvider.value()` for shared BLoCs.
+- **DI & Routing**: Flutter Modular. Safe navigation via `safeNavigate()`, `safePush()`, `popSafe()`. Never `Navigator.push()` directly (except when popping a dialog).
+- **Error Handling**: `BlocErrorHandler` mixin with `_safeEmit()` to prevent StateError on disposed BLoCs.
+- **Backend**: Firebase Data Connect through `data_connect` package Connectors. `_service.run(() => connector.().execute())` for auth/token management.
+- **Session Management**: `SessionHandlerMixin` + `SessionListener` widget.
+- **Localization**: Slang (`t.section.key`), not `context.strings`.
+- **Design System**: Tokens from `UiColors`, `UiTypography`, `UiConstants`. No hardcoded values.
+
+## Primary Responsibilities
+
+### 1. Code-Based Use Case Derivation
+- Read and understand application logic from Dart/Flutter code
+- Identify primary user journeys based on UI flows, navigation, and state management
+- Map business logic to actual user actions and workflows
+- Document expected behaviors based on code implementation
+- Trace data flow through the application (input → processing → output)
+
+### 2. Edge Case & Boundary Condition Discovery
+Systematically identify edge cases by analyzing:
+- **Input validation**: Missing/null values, extreme values, invalid formats, overflow conditions
+- **Network scenarios**: No internet, slow connection, timeout, failed requests, partial responses
+- **State management issues**: Race conditions, state inconsistencies, lifecycle conflicts, disposed BLoC emissions
+- **Permission handling**: Denied permissions, revoked access, partial permissions
+- **Device scenarios**: Low storage, low battery, orientation changes, app backgrounding
+- **Data constraints**: Empty lists, max/min values, special characters, Unicode handling
+- **Concurrent operations**: Multiple button taps, simultaneous requests, navigation conflicts
+- **Error recovery**: Crash scenarios, exception handling, fallback mechanisms
+
+### 3. Issue Identification & Analysis
+Detect potential bugs including:
+- **Logic errors**: Incorrect conditions, wrong operators, missing checks
+- **UI/UX problems**: Unhandled states, broken navigation, poor error messaging
+- **State management flaws**: Lost data, stale state, memory leaks, missing `BlocErrorHandler` usage
+- **API integration issues**: Missing error handling, incorrect data mapping, async issues
+- **Performance concerns**: Inefficient algorithms, unnecessary rebuilds, memory problems
+- **Security vulnerabilities**: Hardcoded credentials, insecure data storage, authentication gaps
+- **Architecture violations**: Features importing other features, business logic in BLoCs/widgets, Firebase packages outside `data_connect`
+- **Data persistence issues**: Cache invalidation, concurrent access
+
+## Analysis Methodology
+
+### Phase 1: Code Exploration & Understanding
+1. Map the feature's architecture and key screens
+2. Identify critical user flows and navigation paths
+3. Review state management implementation (BLoC states, events, transitions)
+4. Understand data models and API contracts via Data Connect connectors
+5. Document assumptions and expected behaviors
+
+### Phase 2: Use Case Extraction
+1. List **Happy Path scenarios** (normal, expected usage)
+2. Identify **Alternative Paths** (valid variations)
+3. Define **Error Scenarios** (what can go wrong)
+4. Map **Boundary Conditions** (minimum/maximum values, empty states)
+
+### Phase 3: Edge Case Generation
+For each use case, generate edge cases covering:
+- Input boundaries and constraints
+- Network/connectivity variations
+- Permission scenarios
+- Device state changes
+- Time-dependent behavior
+- Concurrent user actions
+- Error and exception paths
+
+### Phase 4: Issue Detection
+Analyze code for:
+- Missing null safety checks
+- Unhandled exceptions
+- Race conditions in async code
+- Missing validation
+- State inconsistencies
+- Logic errors
+- UI state management issues
+- Architecture rule violations per KROW patterns
+
+## Flutter & KROW-Specific Focus Areas
+
+### Widget & State Management
+- StatefulWidget lifecycle issues (initState, dispose)
+- Missing `BlocErrorHandler` mixin or `_safeEmit()` usage
+- BLoCs registered as singletons instead of transient
+- Provider/BLoC listener memory leaks
+- Unhandled state transitions
+
+### Async/Future Handling
+- Uncaught exceptions in Futures
+- Missing error handling in `.then()` chains
+- Mounted checks missing in async callbacks
+- Race conditions in concurrent requests
+- Missing `_service.run()` wrapper for Data Connect calls
+
+### Background Tasks & WorkManager
+When reviewing code that uses WorkManager or background task scheduling, check these edge cases:
+- **App backgrounded**: Does the background task work when the app is in the background? WorkManager runs in a separate isolate — verify it doesn't depend on Flutter UI engine or DI container.
+- **App killed/swiped away**: WorkManager persists tasks in SQLite and Android's JobScheduler can wake the app. Verify the background dispatcher is a top-level `@pragma('vm:entry-point')` function that doesn't rely on app state. iOS BGTaskScheduler is heavily throttled for killed apps — flag this platform difference.
+- **Screen off / Doze mode**: Android batches tasks for battery efficiency. Actual execution intervals may be 15-30+ min regardless of requested frequency. Flag any code that assumes exact timing.
+- **Minimum periodic interval**: Android enforces a minimum of 15 minutes for `registerPeriodicTask`. Any frequency below this is silently clamped. Flag code requesting shorter intervals as misleading.
+- **Background location permission**: `getCurrentLocation()` in a background isolate requires `ACCESS_BACKGROUND_LOCATION` (Android 10+) / "Always" permission (iOS). Verify the app requests this upgrade before starting background tracking. Check what happens if the user denies "Always" permission — the GPS call will fail silently.
+- **Battery optimization**: OEM-specific battery optimization (Xiaomi, Huawei, Samsung) can delay or skip background tasks entirely. Flag if there's no guidance to users about whitelisting the app.
+- **Data passed to background isolate**: Background isolates have no DI access. Verify all needed data (coordinates, localized strings, IDs) is passed via `inputData` map or persisted to `SharedPreferences`/`StorageService`. Flag any hardcoded user-facing strings that should be localized.
+- **Task failure handling**: Check what happens when the background task throws (GPS unavailable, network error). Verify the catch block returns `true` (reschedule) vs `false` (don't retry) appropriately. Check if repeated failures are tracked or silently swallowed.
+- **Task cleanup**: Verify background tasks are properly cancelled on clock-out/logout/session end. Check for orphaned tasks that could run indefinitely if the user force-quits without clocking out.
+
+### Navigation & Routing (Flutter Modular)
+- Direct `Navigator.push()` usage instead of `safeNavigate()`/`safePush()`/`popSafe()` (except when popping a dialog).
+- Back button behavior edge cases
+- Deep link handling
+- State loss during navigation
+- Duplicate navigation calls
+
+### Localization
+- Hardcoded strings instead of `t.section.key`
+- Missing translations in both `en.i18n.json` and `es.i18n.json`
+- `context.strings` usage instead of Slang `t.*`
+
+### Design System
+- Hardcoded colors, fonts, or spacing instead of `UiColors`, `UiTypography`, `UiConstants`
+
+### Architecture Rules
+- Features importing other features directly
+- Business logic in BLoCs or widgets instead of Use Cases
+- Firebase packages used outside `data_connect` package
+- `context.read()` instead of `ReadContext(context).read()`
+
+## Output Format
+
+For each feature/screen analyzed, provide:
+
+```
+## [Feature/Screen Name]
+
+### Use Cases Identified
+1. **Primary Path**: [Description of normal usage]
+2. **Alternative Path**: [Valid variations]
+3. **Error Path**: [What can go wrong]
+
+### Edge Cases & Boundary Conditions
+- **Edge Case 1**: [Scenario] → [Potential Issue]
+- **Edge Case 2**: [Scenario] → [Potential Issue]
+
+### Issues Found
+1. **[Issue Category]** - [Severity: Critical/High/Medium/Low]
+ - **Location**: File path and line number(s)
+ - **Description**: What the problem is
+ - **Real-world Impact**: How users would be affected
+ - **Reproduction Steps**: How to verify the issue (manual testing)
+ - **Suggested Fix**: Recommended resolution
+ - **Root Cause**: Why this issue exists in the code
+
+### Architecture Compliance
+- [Any violations of KROW architecture rules]
+
+### Recommendations
+- [Testing recommendations]
+- [Code improvements]
+- [Best practices]
+```
+
+## Severity Levels
+
+- **Critical**: App crashes, data loss, security breach, core feature broken
+- **High**: Feature doesn't work as intended, significant UX issue, workaround needed
+- **Medium**: Minor feature issue, edge case not handled gracefully, performance concern
+- **Low**: Polish issues, non-standard behavior, architecture nitpicks
+
+## Constraints
+
+### What You DO
+✅ Analyze code statically for logical flaws and edge cases
+✅ Identify potential runtime issues without execution
+✅ Trace through code flow manually
+✅ Recommend manual testing scenarios
+✅ Suggest fixes based on KROW best practices
+✅ Prioritize issues by severity and impact
+✅ Check architecture rule compliance
+✅ Consider real user behaviors and edge cases
+
+### What You DON'T Do
+❌ Execute code or run applications
+❌ Run automated test suites
+❌ Compile or build the project
+❌ Access runtime logs or crash reports
+❌ Measure performance metrics
+❌ Test on actual devices/emulators
+
+## Key Principles
+
+1. **Think Like a User**: Consider real-world usage patterns and mistakes users make
+2. **Assume Worst Case**: Network fails, permissions denied, storage full, etc.
+3. **Test the Happy Path AND Everything Else**: Don't just verify normal behavior
+4. **Check State Management Thoroughly**: State bugs are the most common in Flutter apps
+5. **Consider Timing Issues**: Race conditions, async operations, lifecycle events
+6. **Platform Awareness**: Remember iOS and Android behave differently
+7. **Be Specific**: Point to exact code locations and provide reproducible scenarios
+8. **Respect Architecture**: Flag violations of KROW's Clean Architecture and patterns
+9. **Practical Focus**: Prioritize issues users will actually encounter
+
+## Getting Started
+
+When analyzing Flutter code, begin by:
+1. Reading the feature's module file to understand routing and DI setup
+2. Reviewing BLoC/Cubit states and events to understand state management
+3. Tracing user flows through pages and widgets
+4. Checking data flow from UI through use cases to repositories
+5. Identifying all async operations and error handling paths
+6. Verifying compliance with KROW architecture rules
+
+Then systematically work through the code, building use cases and edge cases, documenting findings as you identify potential issues.
+
+**Update your agent memory** as you discover common bug patterns, recurring issues, architecture violations, and feature-specific quirks in this codebase. This builds up institutional knowledge across conversations. Write concise notes about what you found and where.
+
+Examples of what to record:
+- Common patterns that lead to bugs (e.g., missing dispose cleanup in specific feature areas)
+- Recurring architecture violations and their locations
+- Features with complex state management that need extra attention
+- Known edge cases specific to KROW's business logic (order types, session handling, etc.)
+- Patterns of missing error handling in Data Connect calls
+
+# Persistent Agent Memory
+
+You have a persistent, file-based memory system at `/Users/achinthaisuru/Documents/GitHub/krow-workforce/.claude/agent-memory/mobile-qa-analyst/`. This directory already exists — write to it directly with the Write tool (do not run mkdir or check for its existence).
+
+You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you.
+
+If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry.
+
+## Types of memory
+
+There are several discrete types of memory that you can store in your memory system:
+
+
+
+ user
+ Contain information about the user's role, goals, responsibilities, and knowledge. Great user memories help you tailor your future behavior to the user's preferences and perspective. Your goal in reading and writing these memories is to build up an understanding of who the user is and how you can be most helpful to them specifically. For example, you should collaborate with a senior software engineer differently than a student who is coding for the very first time. Keep in mind, that the aim here is to be helpful to the user. Avoid writing memories about the user that could be viewed as a negative judgement or that are not relevant to the work you're trying to accomplish together.
+ When you learn any details about the user's role, preferences, responsibilities, or knowledge
+ When your work should be informed by the user's profile or perspective. For example, if the user is asking you to explain a part of the code, you should answer that question in a way that is tailored to the specific details that they will find most valuable or that helps them build their mental model in relation to domain knowledge they already have.
+
+ user: I'm a data scientist investigating what logging we have in place
+ assistant: [saves user memory: user is a data scientist, currently focused on observability/logging]
+
+ user: I've been writing Go for ten years but this is my first time touching the React side of this repo
+ assistant: [saves user memory: deep Go expertise, new to React and this project's frontend — frame frontend explanations in terms of backend analogues]
+
+
+
+ feedback
+ Guidance or correction the user has given you. 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. Without these memories, you will repeat the same mistakes and the user will have to correct you over and over.
+ Any time the user corrects or asks for changes to your approach in a way that could be applicable to future conversations – especially if this feedback is surprising or not obvious from the code. These often take the form of "no not that, instead do...", "lets not...", "don't...". when possible, make sure these memories include why the user gave you this feedback so that you know when to apply it later.
+ Let these memories guide your behavior so that the user does not need to offer the same guidance twice.
+ Lead with the rule itself, then a **Why:** line (the reason the user gave — often a past incident or strong preference) and a **How to apply:** line (when/where this guidance kicks in). Knowing *why* lets you judge edge cases instead of blindly following the rule.
+
+ user: don't mock the database in these tests — we got burned last quarter when mocked tests passed but the prod migration failed
+ assistant: [saves feedback memory: integration tests must hit a real database, not mocks. Reason: prior incident where mock/prod divergence masked a broken migration]
+
+ user: stop summarizing what you just did at the end of every response, I can read the diff
+ assistant: [saves feedback memory: this user wants terse responses with no trailing summaries]
+
+
+
+ project
+ Information that you learn about ongoing work, goals, initiatives, bugs, or incidents within the project that is not otherwise derivable from the code or git history. Project memories help you understand the broader context and motivation behind the work the user is doing within this working directory.
+ When you learn who is doing what, why, or by when. These states change relatively quickly so try to keep your understanding of this up to date. Always convert relative dates in user messages to absolute dates when saving (e.g., "Thursday" → "2026-03-05"), so the memory remains interpretable after time passes.
+ Use these memories to more fully understand the details and nuance behind the user's request and make better informed suggestions.
+ Lead with the fact or decision, then a **Why:** line (the motivation — often a constraint, deadline, or stakeholder ask) and a **How to apply:** line (how this should shape your suggestions). Project memories decay fast, so the why helps future-you judge whether the memory is still load-bearing.
+
+ user: we're freezing all non-critical merges after Thursday — mobile team is cutting a release branch
+ assistant: [saves project memory: merge freeze begins 2026-03-05 for mobile release cut. Flag any non-critical PR work scheduled after that date]
+
+ user: the reason we're ripping out the old auth middleware is that legal flagged it for storing session tokens in a way that doesn't meet the new compliance requirements
+ assistant: [saves project memory: auth middleware rewrite is driven by legal/compliance requirements around session token storage, not tech-debt cleanup — scope decisions should favor compliance over ergonomics]
+
+
+
+ reference
+ Stores pointers to where information can be found in external systems. These memories allow you to remember where to look to find up-to-date information outside of the project directory.
+ When you learn about resources in external systems and their purpose. For example, that bugs are tracked in a specific project in Linear or that feedback can be found in a specific Slack channel.
+ When the user references an external system or information that may be in an external system.
+
+ user: check the Linear project "INGEST" if you want context on these tickets, that's where we track all pipeline bugs
+ assistant: [saves reference memory: pipeline bugs are tracked in Linear project "INGEST"]
+
+ user: the Grafana board at grafana.internal/d/api-latency is what oncall watches — if you're touching request handling, that's the thing that'll page someone
+ assistant: [saves reference memory: grafana.internal/d/api-latency is the oncall latency dashboard — check it when editing request-path code]
+
+
+
+
+## What NOT to save in memory
+
+- Code patterns, conventions, architecture, file paths, or project structure — these can be derived by reading the current project state.
+- Git history, recent changes, or who-changed-what — `git log` / `git blame` are authoritative.
+- Debugging solutions or fix recipes — the fix is in the code; the commit message has the context.
+- Anything already documented in CLAUDE.md files.
+- Ephemeral task details: in-progress work, temporary state, current conversation context.
+
+## How to save memories
+
+Saving a memory is a two-step process:
+
+**Step 1** — write the memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format:
+
+```markdown
+---
+name: {{memory name}}
+description: {{one-line description — used to decide relevance in future conversations, so be specific}}
+type: {{user, feedback, project, reference}}
+---
+
+{{memory content — for feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines}}
+```
+
+**Step 2** — add a pointer to that file in `MEMORY.md`. `MEMORY.md` is an index, not a memory — it should contain only links to memory files with brief descriptions. It has no frontmatter. Never write memory content directly into `MEMORY.md`.
+
+- `MEMORY.md` is always loaded into your conversation context — lines after 200 will be truncated, so keep the index concise
+- Keep the name, description, and type fields in memory files up-to-date with the content
+- Organize memory semantically by topic, not chronologically
+- Update or remove memories that turn out to be wrong or outdated
+- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.
+
+## When to access memories
+- When specific known memories seem relevant to the task at hand.
+- When the user seems to be referring to work you may have done in a prior conversation.
+- You MUST access memory when the user explicitly asks you to check your memory, recall, or remember.
+
+## Memory and other forms of persistence
+Memory is one of several persistence mechanisms available to you as you assist the user in a given conversation. The distinction is often that memory can be recalled in future conversations and should not be used for persisting information that is only useful within the scope of the current conversation.
+- When to use or update a plan instead of memory: If you are about to start a non-trivial implementation task and would like to reach alignment with the user on your approach you should use a Plan rather than saving this information to memory. Similarly, if you already have a plan within the conversation and you have changed your approach persist that change by updating the plan rather than saving a memory.
+- When to use or update tasks instead of memory: When you need to break your work in current conversation into discrete steps or keep track of your progress use tasks instead of saving to memory. Tasks are great for persisting information about the work that needs to be done in the current conversation, but memory should be reserved for information that will be useful in future conversations.
+
+- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
+
+## MEMORY.md
+
+Your MEMORY.md is currently empty. When you save new memories, they will appear here.
diff --git a/.claude/skills/krow-paper-design/SKILL.md b/.claude/skills/krow-paper-design/SKILL.md
index 93860546..f7c92a61 100644
--- a/.claude/skills/krow-paper-design/SKILL.md
+++ b/.claude/skills/krow-paper-design/SKILL.md
@@ -190,10 +190,22 @@ All chips: border 1.5px, text Manrope 14px/600, gap 8px for icon+text
- Active (filled): bg `#0A39DF`, radius 8px, padding 6px/12px
- Text Manrope 12px/600 `#FFFFFF`
-**Status Badges:**
+**XSmall (Status Chips):**
+- For inline status indicators on list rows, section overviews, and cards
+- Height: ~20px, padding: 3px/8px, no border
+- Text: Manrope 11px/700, uppercase, letter-spacing 0.03-0.04em
+- Variants:
+ - Required/Pending: bg `#FEF9EE`, text `#D97706`, radius 6px
+ - Active/Complete: bg `#ECFDF5`, text `#059669`, radius 6px
+ - Confirmed/Info: bg `#E9F0FF`, text `#0A39DF`, radius 6px
+ - Error/Rejected: bg `#FEF2F2`, text `#F04444`, radius 6px
+ - Neutral/Disabled: bg `#F1F3F5`, text `#94A3B8`, radius 6px
+
+**Status Badges (legacy):**
- Radius: 8px, padding: 4px/8px
- Text: Manrope 11px/600-700, uppercase, letter-spacing 0.04em
- Colors follow semantic badge table above
+- Prefer XSmall Chips for new designs
### Text Inputs
@@ -247,6 +259,24 @@ All chips: border 1.5px, text Manrope 14px/600, gap 8px for icon+text
- Value: Inter Tight 20px/700 `#121826`
- Layout: flex row, equal width columns, gap 8px
+### Notice Banners
+
+Contextual banners for alerts, warnings, and informational notices. Used in forms, review screens, and detail pages.
+
+- Container: radius 10px, padding 14px, gap 6px, flex column
+- Icon + Title row: flex row, gap 8-10px, align center
+- Icon: 18×18 SVG, same color as text
+- Title: Manrope 14px/600, line-height 18px
+- Body: Manrope 12px/400, line-height 18px
+
+**Variants:**
+| Variant | Background | Color | Title Weight | Icon |
+|---------|-----------|-------|-------------|------|
+| Error | `#FEF2F2` | `#F04444` | 600 | ⊗ (circle-x) |
+| Warning | `#FEF9EE` | `#E6A817` | 600 | △ (triangle-alert) |
+| Info/Notice | `#E9F0FF` | `#0A39DF` | 600 | ⓘ (circle-info) |
+| Success | `#ECFDF5` | `#059669` | 600 | ✓ (circle-check) |
+
### Contact/Info Rows
- Container: radius 12px, border 0.5px `#D1D5DB`, background `#FFFFFF`, overflow clip
@@ -255,6 +285,44 @@ All chips: border 1.5px, text Manrope 14px/600, gap 8px for icon+text
- Label: Manrope 13px/500 `#6A7382`, width 72px fixed
- Value: Manrope 13px/500 `#121826` (or `#0A39DF` for phone/links)
+### Shift Cards
+
+Two variants for displaying shifts in lists. Cards are grouped under month headers.
+
+**Common card container:**
+- Background: `#FFFFFF`, border: 0.5px `#D1D5DB`, radius: 12px, padding: 16px, gap: 12px
+
+**Header row** (top of card):
+- Layout: flex row, space-between
+- Left side: Role title + Venue subtitle (stacked)
+ - Role: Inter Tight 16px/600 `#121826` (primary — always most prominent)
+ - Venue: Manrope 13px/400 `#6A7382`
+- Right side: XSmall status chip (flex-shrink 0)
+
+**Details row** (bottom of card):
+- Layout: flex row, space-between, align start
+- Left column (flex column, gap 6px): date, time, location — each as icon (16px `#6A7382`) + text (Manrope 13px/500-600 `#6A7382`) row with 6px gap
+- Right column (earnings — only in Variant 1)
+
+**Variant 1 — With Earnings (Completed shifts):**
+- Right side shows earnings, right-aligned:
+ - Amount: Inter Tight 14px/600 `#121826` (e.g., "$192.00")
+ - Rate below: Manrope 13px/500 `#6A7382` (e.g., "6 hrs · $32/hr")
+
+**Variant 2 — Without Earnings (Cancelled, No-Show, Upcoming):**
+- No right-side earnings section — details row takes full width
+
+**Status chip variants on shift cards:**
+| Status | Background | Text |
+|--------|-----------|------|
+| Confirmed | `#E9F0FF` | `#0A39DF` |
+| Active | `#ECFDF5` | `#059669` |
+| Pending | `#FEF9EE` | `#D97706` |
+| Completed | `#ECFDF5` | `#059669` |
+| Swap Requested | `#FEF9EE` | `#D97706` |
+| No-Show | `#FEF2F2` | `#F04444` |
+| Cancelled | `#F1F3F5` | `#6A7382` |
+
### Section Headers
- Text: Manrope 11px/600, uppercase, letter-spacing 0.06em, color `#6A7382`
@@ -379,7 +447,27 @@ Artboard (390x844, bg #FAFBFC)
Bottom CTAs (primary + outline)
```
-## 5. Workflow Rules
+## 5. Interaction Patterns
+
+### Modals → Bottom Sheets
+All modal/dialog interactions MUST use bottom sheets, never centered modal dialogs.
+- Sheet: white bg, 18px top-left/top-right radius, padding 24px, bottom safe area 34px
+- Handle bar: 40px wide, 4px height, `#D1D5DB`, centered, 999px radius, 8px margin-bottom
+- Overlay: `rgba(18, 24, 38, 0.55)` scrim behind sheet
+- Title: Inter Tight 20px/700, `#121826`
+- Subtitle: Manrope 13px/400, `#6A7382`
+- Primary CTA: full-width at bottom of sheet
+- Dismiss: "Skip" or "Cancel" text link below CTA, or swipe-down gesture
+
+### Long Lists with Date Filters
+When displaying lists with date filtering (e.g., shift history, timecards, payment history):
+- Group items by **month** (e.g., "MARCH 2026", "FEBRUARY 2026")
+- Month headers use Overline Label style: Manrope 11px/600, uppercase, `#6A7382`, letter-spacing 0.06em
+- Gap: 10px below month header to first item, 24px between month groups
+- Most recent month first (reverse chronological)
+- Date filter at top (chip or dropdown): "Last 30 days", "Last 3 months", "This year", custom range
+
+## 6. Workflow Rules
### Write Incrementally
@@ -417,7 +505,7 @@ When creating matching screens (e.g., two shift detail views):
- Use same card/row component patterns
- Maintain consistent padding and gap values
-## 6. SVG Icon Patterns
+## 7. SVG Icon Patterns
### Chevron Left (Back)
```html
@@ -470,7 +558,7 @@ When creating matching screens (e.g., two shift detail views):
```
-## 7. Anti-Patterns
+## 8. Anti-Patterns
### Colors
- Never use `#0F4C81`, `#1A3A5C` (old navy) - use `#0A39DF` (Primary)
@@ -491,6 +579,8 @@ When creating matching screens (e.g., two shift detail views):
- Never skip review checkpoints after 2-3 modifications
- Never create frames without following the naming convention
- Never use `justifyContent: space-between` on artboards with many direct children - use `marginTop: auto` on the CTA instead
+- Never use centered modal dialogs — always use bottom sheets for modal interactions
+- Never show long date-filtered lists without grouping by month
## Summary
diff --git a/apps/mobile/apps/client/android/app/build.gradle.kts b/apps/mobile/apps/client/android/app/build.gradle.kts
index 837bc911..a6fe31ec 100644
--- a/apps/mobile/apps/client/android/app/build.gradle.kts
+++ b/apps/mobile/apps/client/android/app/build.gradle.kts
@@ -46,6 +46,7 @@ android {
ndkVersion = flutter.ndkVersion
compileOptions {
+ isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
@@ -122,6 +123,10 @@ afterEvaluate {
}
}
+dependencies {
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
+}
+
flutter {
source = "../.."
}
diff --git a/apps/mobile/apps/client/android/app/src/dev/res/values/ic_launcher.xml b/apps/mobile/apps/client/android/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml
similarity index 100%
rename from apps/mobile/apps/client/android/app/src/dev/res/values/ic_launcher.xml
rename to apps/mobile/apps/client/android/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml
diff --git a/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java
index e6d40294..bab9899d 100644
--- a/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java
+++ b/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java
@@ -35,16 +35,31 @@ public final class GeneratedPluginRegistrant {
} catch (Exception e) {
Log.e(TAG, "Error registering plugin firebase_core, io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin", e);
}
+ try {
+ flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
+ } catch (Exception e) {
+ Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
+ }
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e);
}
+ try {
+ flutterEngine.getPlugins().add(new com.baseflow.geolocator.GeolocatorPlugin());
+ } catch (Exception e) {
+ Log.e(TAG, "Error registering plugin geolocator_android, com.baseflow.geolocator.GeolocatorPlugin", e);
+ }
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin image_picker_android, io.flutter.plugins.imagepicker.ImagePickerPlugin", e);
}
+ try {
+ flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
+ } catch (Exception e) {
+ Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e);
+ }
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
} catch (Exception e) {
@@ -65,5 +80,10 @@ public final class GeneratedPluginRegistrant {
} catch (Exception e) {
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
}
+ try {
+ flutterEngine.getPlugins().add(new dev.fluttercommunity.workmanager.WorkmanagerPlugin());
+ } catch (Exception e) {
+ Log.e(TAG, "Error registering plugin workmanager_android, dev.fluttercommunity.workmanager.WorkmanagerPlugin", e);
+ }
}
}
diff --git a/apps/mobile/apps/client/android/app/src/stage/res/values/ic_launcher.xml b/apps/mobile/apps/client/android/app/src/stage/res/mipmap-anydpi-v26/ic_launcher.xml
similarity index 100%
rename from apps/mobile/apps/client/android/app/src/stage/res/values/ic_launcher.xml
rename to apps/mobile/apps/client/android/app/src/stage/res/mipmap-anydpi-v26/ic_launcher.xml
diff --git a/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m b/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m
index 241fcf3b..adab234d 100644
--- a/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m
+++ b/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m
@@ -30,12 +30,30 @@
@import firebase_core;
#endif
+#if __has_include()
+#import
+#else
+@import flutter_local_notifications;
+#endif
+
+#if __has_include()
+#import
+#else
+@import geolocator_apple;
+#endif
+
#if __has_include()
#import
#else
@import image_picker_ios;
#endif
+#if __has_include()
+#import
+#else
+@import package_info_plus;
+#endif
+
#if __has_include()
#import
#else
@@ -54,6 +72,12 @@
@import url_launcher_ios;
#endif
+#if __has_include()
+#import
+#else
+@import workmanager_apple;
+#endif
+
@implementation GeneratedPluginRegistrant
+ (void)registerWithRegistry:(NSObject*)registry {
@@ -61,10 +85,14 @@
[FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]];
[FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]];
[FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]];
+ [FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]];
+ [GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
+ [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]];
[RecordIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"RecordIosPlugin"]];
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
+ [WorkmanagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"WorkmanagerPlugin"]];
}
@end
diff --git a/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift
index 1dea22d7..288fbc2c 100644
--- a/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -10,6 +10,9 @@ import file_selector_macos
import firebase_app_check
import firebase_auth
import firebase_core
+import flutter_local_notifications
+import geolocator_apple
+import package_info_plus
import record_macos
import shared_preferences_foundation
import url_launcher_macos
@@ -20,6 +23,9 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
+ FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
+ GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
+ FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
diff --git a/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc b/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc
index 3c9a7f78..fc95dec8 100644
--- a/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc
+++ b/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc
@@ -9,6 +9,7 @@
#include
#include
#include
+#include
#include
#include
@@ -19,6 +20,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
+ GeolocatorWindowsRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("GeolocatorWindows"));
RecordWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
diff --git a/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake b/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake
index f2ab3101..15f2a4c5 100644
--- a/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake
+++ b/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake
@@ -6,11 +6,13 @@ list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
firebase_auth
firebase_core
+ geolocator_windows
record_windows
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
+ flutter_local_notifications_windows
)
set(PLUGIN_BUNDLED_LIBRARIES)
diff --git a/apps/mobile/apps/staff/android/app/build.gradle.kts b/apps/mobile/apps/staff/android/app/build.gradle.kts
index 84df41b4..07e23c5a 100644
--- a/apps/mobile/apps/staff/android/app/build.gradle.kts
+++ b/apps/mobile/apps/staff/android/app/build.gradle.kts
@@ -46,6 +46,7 @@ android {
ndkVersion = flutter.ndkVersion
compileOptions {
+ isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
@@ -127,6 +128,10 @@ afterEvaluate {
}
}
+dependencies {
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
+}
+
flutter {
source = "../.."
}
diff --git a/apps/mobile/apps/staff/android/app/src/dev/res/values/ic_launcher.xml b/apps/mobile/apps/staff/android/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml
similarity index 100%
rename from apps/mobile/apps/staff/android/app/src/dev/res/values/ic_launcher.xml
rename to apps/mobile/apps/staff/android/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml
diff --git a/apps/mobile/apps/staff/android/app/src/main/AndroidManifest.xml b/apps/mobile/apps/staff/android/app/src/main/AndroidManifest.xml
index 9416b135..7e576610 100644
--- a/apps/mobile/apps/staff/android/app/src/main/AndroidManifest.xml
+++ b/apps/mobile/apps/staff/android/app/src/main/AndroidManifest.xml
@@ -1,4 +1,8 @@
+
+
+
+ )
+#import
+#else
+@import flutter_local_notifications;
+#endif
+
#if __has_include()
#import
#else
@@ -48,10 +54,10 @@
@import image_picker_ios;
#endif
-#if __has_include()
-#import
+#if __has_include()
+#import
#else
-@import permission_handler_apple;
+@import package_info_plus;
#endif
#if __has_include()
@@ -78,6 +84,12 @@
@import url_launcher_ios;
#endif
+#if __has_include()
+#import
+#else
+@import workmanager_apple;
+#endif
+
@implementation GeneratedPluginRegistrant
+ (void)registerWithRegistry:(NSObject*)registry {
@@ -85,14 +97,16 @@
[FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]];
[FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]];
[FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]];
+ [FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]];
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
[FLTGoogleMapsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTGoogleMapsPlugin"]];
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
- [PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]];
+ [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]];
[RecordIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"RecordIosPlugin"]];
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
[SmartAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"SmartAuthPlugin"]];
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
+ [WorkmanagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"WorkmanagerPlugin"]];
}
@end
diff --git a/apps/mobile/apps/staff/ios/Runner/Info.plist b/apps/mobile/apps/staff/ios/Runner/Info.plist
index bdc600e2..9bb97fda 100644
--- a/apps/mobile/apps/staff/ios/Runner/Info.plist
+++ b/apps/mobile/apps/staff/ios/Runner/Info.plist
@@ -45,6 +45,14 @@
UIApplicationSupportsIndirectInputEvents
+ NSLocationWhenInUseUsageDescription
+ We need your location to verify you are at your assigned workplace for clock-in.
+ NSLocationAlwaysAndWhenInUseUsageDescription
+ We need your location to verify you remain at your assigned workplace during your shift.
+ NSLocationAlwaysUsageDescription
+ We need your location to verify you remain at your assigned workplace during your shift.
+ UIBackgroundModes
+ locationDART_DEFINES$(DART_DEFINES)
diff --git a/apps/mobile/apps/staff/lib/main.dart b/apps/mobile/apps/staff/lib/main.dart
index a50744c9..34a7321e 100644
--- a/apps/mobile/apps/staff/lib/main.dart
+++ b/apps/mobile/apps/staff/lib/main.dart
@@ -10,6 +10,8 @@ import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krowwithus_staff/firebase_options.dart';
import 'package:staff_authentication/staff_authentication.dart'
as staff_authentication;
+import 'package:staff_clock_in/staff_clock_in.dart'
+ show backgroundGeofenceDispatcher;
import 'package:staff_main/staff_main.dart' as staff_main;
import 'src/widgets/session_listener.dart';
@@ -18,6 +20,9 @@ void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
+ // Initialize background task processing for geofence checks
+ await const BackgroundTaskService().initialize(backgroundGeofenceDispatcher);
+
// Register global BLoC observer for centralized error logging
Bloc.observer = CoreBlocObserver(
logEvents: true,
diff --git a/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift
index 6cef433c..ced6d7c8 100644
--- a/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -10,7 +10,9 @@ import file_selector_macos
import firebase_app_check
import firebase_auth
import firebase_core
+import flutter_local_notifications
import geolocator_apple
+import package_info_plus
import record_macos
import shared_preferences_foundation
import smart_auth
@@ -22,7 +24,9 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
+ FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
+ FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SmartAuthPlugin.register(with: registry.registrar(forPlugin: "SmartAuthPlugin"))
diff --git a/apps/mobile/apps/staff/pubspec.yaml b/apps/mobile/apps/staff/pubspec.yaml
index 21c19091..dd289c30 100644
--- a/apps/mobile/apps/staff/pubspec.yaml
+++ b/apps/mobile/apps/staff/pubspec.yaml
@@ -34,6 +34,7 @@ dependencies:
flutter_modular: ^6.3.0
firebase_core: ^4.4.0
flutter_bloc: ^8.1.6
+ workmanager: ^0.9.0+3
dev_dependencies:
flutter_test:
diff --git a/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc b/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc
index e810dd28..e03cd33a 100644
--- a/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc
+++ b/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc
@@ -10,7 +10,6 @@
#include
#include
#include
-#include
#include
#include
#include
@@ -24,8 +23,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
GeolocatorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GeolocatorWindows"));
- PermissionHandlerWindowsPluginRegisterWithRegistrar(
- registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
RecordWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
SmartAuthPluginRegisterWithRegistrar(
diff --git a/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake b/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake
index 8eade70f..09b5070b 100644
--- a/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake
+++ b/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake
@@ -7,13 +7,13 @@ list(APPEND FLUTTER_PLUGIN_LIST
firebase_auth
firebase_core
geolocator_windows
- permission_handler_windows
record_windows
smart_auth
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
+ flutter_local_notifications_windows
)
set(PLUGIN_BUNDLED_LIBRARIES)
diff --git a/apps/mobile/packages/core/lib/core.dart b/apps/mobile/packages/core/lib/core.dart
index e8743adc..600ff74f 100644
--- a/apps/mobile/packages/core/lib/core.dart
+++ b/apps/mobile/packages/core/lib/core.dart
@@ -5,6 +5,8 @@ export 'src/core_module.dart';
export 'src/domain/arguments/usecase_argument.dart';
export 'src/domain/usecases/usecase.dart';
export 'src/utils/date_time_utils.dart';
+export 'src/utils/geo_utils.dart';
+export 'src/utils/time_utils.dart';
export 'src/presentation/widgets/web_mobile_frame.dart';
export 'src/presentation/mixins/bloc_error_handler.dart';
export 'src/presentation/observers/core_bloc_observer.dart';
@@ -33,3 +35,7 @@ export 'src/services/device/gallery/gallery_service.dart';
export 'src/services/device/file/file_picker_service.dart';
export 'src/services/device/file_upload/device_file_upload_service.dart';
export 'src/services/device/audio/audio_recorder_service.dart';
+export 'src/services/device/location/location_service.dart';
+export 'src/services/device/notification/notification_service.dart';
+export 'src/services/device/storage/storage_service.dart';
+export 'src/services/device/background_task/background_task_service.dart';
diff --git a/apps/mobile/packages/core/lib/src/core_module.dart b/apps/mobile/packages/core/lib/src/core_module.dart
index 5c71f6aa..1d2c07ea 100644
--- a/apps/mobile/packages/core/lib/src/core_module.dart
+++ b/apps/mobile/packages/core/lib/src/core_module.dart
@@ -48,5 +48,13 @@ class CoreModule extends Module {
apiUploadService: i.get(),
),
);
+
+ // 6. Register Geofence Device Services
+ i.addLazySingleton(() => const LocationService());
+ i.addLazySingleton(() => NotificationService());
+ i.addLazySingleton(() => StorageService());
+ i.addLazySingleton(
+ () => const BackgroundTaskService(),
+ );
}
}
diff --git a/apps/mobile/packages/core/lib/src/services/device/background_task/background_task_service.dart b/apps/mobile/packages/core/lib/src/services/device/background_task/background_task_service.dart
new file mode 100644
index 00000000..d47602f5
--- /dev/null
+++ b/apps/mobile/packages/core/lib/src/services/device/background_task/background_task_service.dart
@@ -0,0 +1,70 @@
+import 'package:krow_domain/krow_domain.dart';
+import 'package:workmanager/workmanager.dart';
+
+/// Service that wraps [Workmanager] for scheduling background tasks.
+class BackgroundTaskService extends BaseDeviceService {
+ /// Creates a [BackgroundTaskService] instance.
+ const BackgroundTaskService();
+
+ /// Initializes the workmanager with the given [callbackDispatcher].
+ Future initialize(Function callbackDispatcher) async {
+ return action(() async {
+ await Workmanager().initialize(callbackDispatcher);
+ });
+ }
+
+ /// Registers a periodic background task with the given [frequency].
+ Future registerPeriodicTask({
+ required String uniqueName,
+ required String taskName,
+ Duration frequency = const Duration(minutes: 15),
+ Map? inputData,
+ }) async {
+ return action(() async {
+ await Workmanager().registerPeriodicTask(
+ uniqueName,
+ taskName,
+ frequency: frequency,
+ inputData: inputData,
+ existingWorkPolicy: ExistingPeriodicWorkPolicy.replace,
+ );
+ });
+ }
+
+ /// Registers a one-off background task.
+ Future registerOneOffTask({
+ required String uniqueName,
+ required String taskName,
+ Map? inputData,
+ }) async {
+ return action(() async {
+ await Workmanager().registerOneOffTask(
+ uniqueName,
+ taskName,
+ inputData: inputData,
+ );
+ });
+ }
+
+ /// Cancels a registered task by its [uniqueName].
+ Future cancelByUniqueName(String uniqueName) async {
+ return action(() => Workmanager().cancelByUniqueName(uniqueName));
+ }
+
+ /// Cancels all registered background tasks.
+ Future cancelAll() async {
+ return action(() => Workmanager().cancelAll());
+ }
+
+ /// Registers the task execution callback for the background isolate.
+ ///
+ /// Must be called inside the top-level callback dispatcher function.
+ /// The [callback] receives the task name and optional input data, and
+ /// must return `true` on success or `false` on failure.
+ void executeTask(
+ Future Function(String task, Map? inputData)
+ callback,
+ ) {
+ Workmanager().executeTask(callback);
+ }
+}
diff --git a/apps/mobile/packages/core/lib/src/services/device/location/location_service.dart b/apps/mobile/packages/core/lib/src/services/device/location/location_service.dart
new file mode 100644
index 00000000..2b583079
--- /dev/null
+++ b/apps/mobile/packages/core/lib/src/services/device/location/location_service.dart
@@ -0,0 +1,110 @@
+import 'dart:async';
+
+import 'package:geolocator/geolocator.dart';
+import 'package:krow_domain/krow_domain.dart';
+
+/// Service that wraps [Geolocator] to provide location access.
+///
+/// This is the only file in the core package that imports geolocator.
+/// All location access across the app should go through this service.
+class LocationService extends BaseDeviceService {
+ /// Creates a [LocationService] instance.
+ const LocationService();
+
+ /// Checks the current permission status and requests permission if needed.
+ Future checkAndRequestPermission() async {
+ return action(() async {
+ final bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
+ if (!serviceEnabled) return LocationPermissionStatus.serviceDisabled;
+
+ LocationPermission permission = await Geolocator.checkPermission();
+ if (permission == LocationPermission.denied) {
+ permission = await Geolocator.requestPermission();
+ }
+
+ return _mapPermission(permission);
+ });
+ }
+
+ /// Requests upgrade to "Always" permission for background location access.
+ Future requestAlwaysPermission() async {
+ return action(() async {
+ // On Android, requesting permission again after whileInUse prompts
+ // for Always.
+ final LocationPermission permission = await Geolocator.requestPermission();
+ return _mapPermission(permission);
+ });
+ }
+
+ /// Returns the device's current location.
+ Future getCurrentLocation() async {
+ return action(() async {
+ final Position position = await Geolocator.getCurrentPosition(
+ locationSettings: const LocationSettings(
+ accuracy: LocationAccuracy.high,
+ ),
+ );
+ return _toDeviceLocation(position);
+ });
+ }
+
+ /// Emits location updates as a stream, filtered by [distanceFilter] meters.
+ Stream watchLocation({int distanceFilter = 10}) {
+ return Geolocator.getPositionStream(
+ locationSettings: LocationSettings(
+ accuracy: LocationAccuracy.high,
+ distanceFilter: distanceFilter,
+ ),
+ ).map(_toDeviceLocation);
+ }
+
+ /// Whether device location services are currently enabled.
+ Future isServiceEnabled() async {
+ return action(() => Geolocator.isLocationServiceEnabled());
+ }
+
+ /// Stream that emits when location service status changes.
+ ///
+ /// Emits `true` when enabled, `false` when disabled.
+ Stream get onServiceStatusChanged {
+ return Geolocator.getServiceStatusStream().map(
+ (ServiceStatus status) => status == ServiceStatus.enabled,
+ );
+ }
+
+ /// Opens the app settings page for the user to manually grant permissions.
+ Future openAppSettings() async {
+ return action(() => Geolocator.openAppSettings());
+ }
+
+ /// Opens the device location settings page.
+ Future openLocationSettings() async {
+ return action(() => Geolocator.openLocationSettings());
+ }
+
+ /// Maps a [LocationPermission] to a [LocationPermissionStatus].
+ LocationPermissionStatus _mapPermission(LocationPermission permission) {
+ switch (permission) {
+ case LocationPermission.always:
+ return LocationPermissionStatus.granted;
+ case LocationPermission.whileInUse:
+ return LocationPermissionStatus.whileInUse;
+ case LocationPermission.denied:
+ return LocationPermissionStatus.denied;
+ case LocationPermission.deniedForever:
+ return LocationPermissionStatus.deniedForever;
+ case LocationPermission.unableToDetermine:
+ return LocationPermissionStatus.denied;
+ }
+ }
+
+ /// Converts a geolocator [Position] to a [DeviceLocation].
+ DeviceLocation _toDeviceLocation(Position position) {
+ return DeviceLocation(
+ latitude: position.latitude,
+ longitude: position.longitude,
+ accuracy: position.accuracy,
+ timestamp: position.timestamp,
+ );
+ }
+}
diff --git a/apps/mobile/packages/core/lib/src/services/device/notification/notification_service.dart b/apps/mobile/packages/core/lib/src/services/device/notification/notification_service.dart
new file mode 100644
index 00000000..fec59c1b
--- /dev/null
+++ b/apps/mobile/packages/core/lib/src/services/device/notification/notification_service.dart
@@ -0,0 +1,79 @@
+import 'package:flutter_local_notifications/flutter_local_notifications.dart';
+import 'package:krow_domain/krow_domain.dart';
+
+/// Service that wraps [FlutterLocalNotificationsPlugin] for local notifications.
+class NotificationService extends BaseDeviceService {
+
+ /// Creates a [NotificationService] with the given [plugin] instance.
+ ///
+ /// If no plugin is provided, a default instance is created.
+ NotificationService({FlutterLocalNotificationsPlugin? plugin})
+ : _plugin = plugin ?? FlutterLocalNotificationsPlugin();
+ /// The underlying notification plugin instance.
+ final FlutterLocalNotificationsPlugin _plugin;
+
+ /// Whether [initialize] has already been called.
+ bool _initialized = false;
+
+ /// Initializes notification channels and requests permissions.
+ ///
+ /// Safe to call multiple times — subsequent calls are no-ops.
+ Future initialize() async {
+ if (_initialized) return;
+ return action(() async {
+ const AndroidInitializationSettings androidSettings = AndroidInitializationSettings(
+ '@mipmap/ic_launcher',
+ );
+ const DarwinInitializationSettings iosSettings = DarwinInitializationSettings(
+ requestAlertPermission: true,
+ requestBadgePermission: true,
+ requestSoundPermission: true,
+ );
+ const InitializationSettings settings = InitializationSettings(
+ android: androidSettings,
+ iOS: iosSettings,
+ );
+ await _plugin.initialize(settings: settings);
+ _initialized = true;
+ });
+ }
+
+ /// Ensures the plugin is initialized before use.
+ Future _ensureInitialized() async {
+ if (!_initialized) await initialize();
+ }
+
+ /// Displays a local notification with the given [title] and [body].
+ Future showNotification({
+ required String title,
+ required String body,
+ int id = 0,
+ }) async {
+ await _ensureInitialized();
+ return action(() async {
+ const AndroidNotificationDetails androidDetails = AndroidNotificationDetails(
+ 'krow_geofence',
+ 'Geofence Notifications',
+ channelDescription: 'Notifications for geofence events',
+ importance: Importance.high,
+ priority: Priority.high,
+ );
+ const DarwinNotificationDetails iosDetails = DarwinNotificationDetails();
+ const NotificationDetails details = NotificationDetails(
+ android: androidDetails,
+ iOS: iosDetails,
+ );
+ await _plugin.show(id: id, title: title, body: body, notificationDetails: details);
+ });
+ }
+
+ /// Cancels a specific notification by [id].
+ Future cancelNotification(int id) async {
+ return action(() => _plugin.cancel(id: id));
+ }
+
+ /// Cancels all active notifications.
+ Future cancelAll() async {
+ return action(() => _plugin.cancelAll());
+ }
+}
diff --git a/apps/mobile/packages/core/lib/src/services/device/storage/storage_service.dart b/apps/mobile/packages/core/lib/src/services/device/storage/storage_service.dart
new file mode 100644
index 00000000..5f14f7f5
--- /dev/null
+++ b/apps/mobile/packages/core/lib/src/services/device/storage/storage_service.dart
@@ -0,0 +1,81 @@
+import 'package:krow_domain/krow_domain.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+/// Service that wraps [SharedPreferences] for key-value storage.
+class StorageService extends BaseDeviceService {
+
+ /// Creates a [StorageService] instance.
+ StorageService();
+ /// Cached preferences instance.
+ SharedPreferences? _prefs;
+
+ /// Returns the [SharedPreferences] instance, initializing lazily.
+ Future get _preferences async {
+ _prefs ??= await SharedPreferences.getInstance();
+ return _prefs!;
+ }
+
+ /// Retrieves a string value for the given [key].
+ Future getString(String key) async {
+ return action(() async {
+ final SharedPreferences prefs = await _preferences;
+ return prefs.getString(key);
+ });
+ }
+
+ /// Stores a string [value] for the given [key].
+ Future setString(String key, String value) async {
+ return action(() async {
+ final SharedPreferences prefs = await _preferences;
+ return prefs.setString(key, value);
+ });
+ }
+
+ /// Retrieves a double value for the given [key].
+ Future getDouble(String key) async {
+ return action(() async {
+ final SharedPreferences prefs = await _preferences;
+ return prefs.getDouble(key);
+ });
+ }
+
+ /// Stores a double [value] for the given [key].
+ Future setDouble(String key, double value) async {
+ return action(() async {
+ final SharedPreferences prefs = await _preferences;
+ return prefs.setDouble(key, value);
+ });
+ }
+
+ /// Retrieves a boolean value for the given [key].
+ Future getBool(String key) async {
+ return action(() async {
+ final SharedPreferences prefs = await _preferences;
+ return prefs.getBool(key);
+ });
+ }
+
+ /// Stores a boolean [value] for the given [key].
+ Future setBool(String key, bool value) async {
+ return action(() async {
+ final SharedPreferences prefs = await _preferences;
+ return prefs.setBool(key, value);
+ });
+ }
+
+ /// Removes the value for the given [key].
+ Future remove(String key) async {
+ return action(() async {
+ final SharedPreferences prefs = await _preferences;
+ return prefs.remove(key);
+ });
+ }
+
+ /// Clears all stored values.
+ Future clear() async {
+ return action(() async {
+ final SharedPreferences prefs = await _preferences;
+ return prefs.clear();
+ });
+ }
+}
diff --git a/apps/mobile/packages/core/lib/src/utils/geo_utils.dart b/apps/mobile/packages/core/lib/src/utils/geo_utils.dart
new file mode 100644
index 00000000..0026273d
--- /dev/null
+++ b/apps/mobile/packages/core/lib/src/utils/geo_utils.dart
@@ -0,0 +1,32 @@
+import 'dart:math';
+
+/// Calculates the distance in meters between two geographic coordinates
+/// using the Haversine formula.
+double calculateDistance(
+ double lat1,
+ double lng1,
+ double lat2,
+ double lng2,
+) {
+ const double earthRadius = 6371000.0;
+ final double dLat = _toRadians(lat2 - lat1);
+ final double dLng = _toRadians(lng2 - lng1);
+ final double a = sin(dLat / 2) * sin(dLat / 2) +
+ cos(_toRadians(lat1)) *
+ cos(_toRadians(lat2)) *
+ sin(dLng / 2) *
+ sin(dLng / 2);
+ final double c = 2 * atan2(sqrt(a), sqrt(1 - a));
+ return earthRadius * c;
+}
+
+/// Formats a distance in meters to a human-readable string.
+String formatDistance(double meters) {
+ if (meters >= 1000) {
+ return '${(meters / 1000).toStringAsFixed(1)} km';
+ }
+ return '${meters.round()} m';
+}
+
+/// Converts degrees to radians.
+double _toRadians(double degrees) => degrees * pi / 180;
diff --git a/apps/mobile/packages/core/lib/src/utils/time_utils.dart b/apps/mobile/packages/core/lib/src/utils/time_utils.dart
new file mode 100644
index 00000000..7340753c
--- /dev/null
+++ b/apps/mobile/packages/core/lib/src/utils/time_utils.dart
@@ -0,0 +1,30 @@
+import 'package:intl/intl.dart';
+
+/// Formats a time string (ISO 8601 or HH:mm) into 12-hour format
+/// (e.g. "9:00 AM").
+///
+/// Returns the original string unchanged if parsing fails.
+String formatTime(String timeStr) {
+ if (timeStr.isEmpty) return '';
+ try {
+ final DateTime dt = DateTime.parse(timeStr);
+ return DateFormat('h:mm a').format(dt);
+ } catch (_) {
+ try {
+ final List parts = timeStr.split(':');
+ if (parts.length >= 2) {
+ final DateTime dt = DateTime(
+ 2022,
+ 1,
+ 1,
+ int.parse(parts[0]),
+ int.parse(parts[1]),
+ );
+ return DateFormat('h:mm a').format(dt);
+ }
+ return timeStr;
+ } catch (_) {
+ return timeStr;
+ }
+ }
+}
diff --git a/apps/mobile/packages/core/pubspec.yaml b/apps/mobile/packages/core/pubspec.yaml
index 15f91f58..f40200eb 100644
--- a/apps/mobile/packages/core/pubspec.yaml
+++ b/apps/mobile/packages/core/pubspec.yaml
@@ -18,6 +18,7 @@ dependencies:
design_system:
path: ../design_system
+ intl: ^0.20.0
flutter_bloc: ^8.1.0
equatable: ^2.0.8
flutter_modular: ^6.4.1
@@ -27,3 +28,7 @@ dependencies:
file_picker: ^8.1.7
record: ^6.2.0
firebase_auth: ^6.1.4
+ geolocator: ^14.0.2
+ flutter_local_notifications: ^21.0.0
+ shared_preferences: ^2.5.4
+ workmanager: ^0.9.0+3
diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json
index 7178240d..f4693848 100644
--- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json
+++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json
@@ -856,10 +856,13 @@
"today_shift_badge": "TODAY'S SHIFT",
"early_title": "You're early!",
"check_in_at": "Check-in available at $time",
+ "early_checkout_title": "Too early to check out",
+ "check_out_at": "Check-out available at $time",
"shift_completed": "Shift Completed!",
"great_work": "Great work today",
"no_shifts_today": "No confirmed shifts for today",
"accept_shift_cta": "Accept a shift to clock in",
+ "per_hr": "\\$$amount/hr",
"soon": "soon",
"checked_in_at_label": "Checked in at",
"not_in_range": "You must be within $distance m to clock in.",
@@ -926,6 +929,39 @@
"submit": "Submit",
"success_title": "Break Logged!",
"close": "Close"
+ },
+ "geofence": {
+ "service_disabled": "Location services are turned off. Enable them to clock in.",
+ "permission_required": "Location permission is required to clock in.",
+ "permission_required_desc": "Grant location permission to verify you're at the workplace when clocking in.",
+ "permission_denied_forever": "Location was permanently denied.",
+ "permission_denied_forever_desc": "Grant location permission in your device settings to verify you're at the workplace when clocking in.",
+ "open_settings": "Open Settings",
+ "grant_permission": "Grant Permission",
+ "verifying": "Verifying your location...",
+ "too_far_title": "You're Too Far Away",
+ "too_far_desc": "You are $distance away. Move within 500m to clock in.",
+ "verified": "Location Verified",
+ "not_in_range": "You must be at the workplace to clock in.",
+ "timeout_title": "Can't Verify Location",
+ "timeout_desc": "Unable to determine your location. You can still clock in with a note.",
+ "timeout_note_hint": "Why can't your location be verified?",
+ "clock_in_greeting_title": "You're Clocked In!",
+ "clock_in_greeting_body": "Have a great shift. We'll keep track of your location.",
+ "background_left_title": "You've Left the Workplace",
+ "background_left_body": "You appear to be more than 500m from your shift location.",
+ "clock_out_title": "You're Clocked Out!",
+ "clock_out_body": "Great work today. See you next shift.",
+ "always_permission_title": "Background Location Needed",
+ "always_permission_desc": "To verify your location during shifts, please allow location access 'Always'.",
+ "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_hint": "Enter your justification...",
+ "override_submit": "Clock In",
+ "overridden_title": "Location Not Verified",
+ "overridden_desc": "You are clocking in without location verification. Your justification has been recorded."
}
},
"availability": {
@@ -1416,6 +1452,10 @@
"application_not_found": "Your application couldn't be found.",
"no_active_shift": "You don't have an active shift to clock out from."
},
+ "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."
+ },
"generic": {
"unknown": "Something went wrong. Please try again.",
"no_connection": "No internet connection. Please check your network and try again.",
diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json
index 5fce4a09..006e3dec 100644
--- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json
+++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json
@@ -851,10 +851,13 @@
"today_shift_badge": "TURNO DE HOY",
"early_title": "\u00a1Ha llegado temprano!",
"check_in_at": "Entrada disponible a las $time",
+ "early_checkout_title": "Muy temprano para salir",
+ "check_out_at": "Salida disponible a las $time",
"shift_completed": "\u00a1Turno completado!",
"great_work": "Buen trabajo hoy",
"no_shifts_today": "No hay turnos confirmados para hoy",
"accept_shift_cta": "Acepte un turno para registrar su entrada",
+ "per_hr": "\\$$amount/hr",
"soon": "pronto",
"checked_in_at_label": "Entrada registrada a las",
"nfc_dialog": {
@@ -921,6 +924,39 @@
"submit": "Enviar",
"success_title": "\u00a1Descanso registrado!",
"close": "Cerrar"
+ },
+ "geofence": {
+ "service_disabled": "Los servicios de ubicación están desactivados. Actívelos para registrar entrada.",
+ "permission_required": "Se requiere permiso de ubicación para registrar entrada.",
+ "permission_required_desc": "Otorgue permiso de ubicación para verificar que está en el lugar de trabajo al registrar entrada.",
+ "permission_denied_forever": "La ubicación fue denegada permanentemente.",
+ "permission_denied_forever_desc": "Otorgue permiso de ubicación en la configuración de su dispositivo para verificar que está en el lugar de trabajo al registrar entrada.",
+ "open_settings": "Abrir Configuración",
+ "grant_permission": "Otorgar Permiso",
+ "verifying": "Verificando su ubicación...",
+ "too_far_title": "Está Demasiado Lejos",
+ "too_far_desc": "Está a $distance de distancia. Acérquese a 500m para registrar entrada.",
+ "verified": "Ubicación Verificada",
+ "not_in_range": "Debe estar en el lugar de trabajo para registrar entrada.",
+ "timeout_title": "No se Puede Verificar la Ubicación",
+ "timeout_desc": "No se pudo determinar su ubicación. Puede registrar entrada con una nota.",
+ "timeout_note_hint": "¿Por qué no se puede verificar su ubicación?",
+ "clock_in_greeting_title": "¡Entrada Registrada!",
+ "clock_in_greeting_body": "Buen turno. Seguiremos el registro de su ubicación.",
+ "background_left_title": "Ha Salido del Lugar de Trabajo",
+ "background_left_body": "Parece que está a más de 500m de la ubicación de su turno.",
+ "clock_out_title": "¡Salida Registrada!",
+ "clock_out_body": "Buen trabajo hoy. Nos vemos en el próximo turno.",
+ "always_permission_title": "Se Necesita Ubicación en Segundo Plano",
+ "always_permission_desc": "Para verificar su ubicación durante los turnos, permita el acceso a la ubicación 'Siempre'.",
+ "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_hint": "Ingrese su justificación...",
+ "override_submit": "Registrar Entrada",
+ "overridden_title": "Ubicación No Verificada",
+ "overridden_desc": "Está registrando entrada sin verificación de ubicación. Su justificación ha sido registrada."
}
},
"availability": {
@@ -1411,6 +1447,10 @@
"application_not_found": "No se pudo encontrar tu solicitud.",
"no_active_shift": "No tienes un turno activo para registrar salida."
},
+ "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."
+ },
"generic": {
"unknown": "Algo sali\u00f3 mal. Por favor, intenta de nuevo.",
"no_connection": "Sin conexi\u00f3n a internet. Por favor, verifica tu red e intenta de nuevo.",
diff --git a/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart b/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart
index 5e7df68d..69e4282d 100644
--- a/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart
+++ b/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart
@@ -35,6 +35,8 @@ String translateErrorKey(String key) {
return _translateProfileError(errorType);
case 'shift':
return _translateShiftError(errorType);
+ case 'clock_in':
+ return _translateClockInError(errorType);
case 'generic':
return _translateGenericError(errorType);
default:
@@ -127,6 +129,18 @@ String _translateShiftError(String errorType) {
}
}
+/// Translates clock-in error keys to localized strings.
+String _translateClockInError(String errorType) {
+ switch (errorType) {
+ case 'location_verification_required':
+ return t.errors.clock_in.location_verification_required;
+ case 'notes_required_for_timeout':
+ return t.errors.clock_in.notes_required_for_timeout;
+ default:
+ return t.errors.generic.unknown;
+ }
+}
+
String _translateGenericError(String errorType) {
switch (errorType) {
case 'unknown':
diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart
index e8513f1d..a23992d4 100644
--- a/apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart
+++ b/apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart
@@ -432,9 +432,9 @@ class ShiftsConnectorRepositoryImpl implements ShiftsConnectorRepository {
.dayEnd(_service.toTimestamp(dayEndUtc))
.execute();
- if (validationResponse.data.applications.isNotEmpty) {
- throw Exception('The user already has a shift that day.');
- }
+ // if (validationResponse.data.applications.isNotEmpty) {
+ // throw Exception('The user already has a shift that day.');
+ // }
}
// Check for existing application
diff --git a/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart b/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart
index 430d163d..478f0c91 100644
--- a/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart
+++ b/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart
@@ -15,18 +15,32 @@ class UiNoticeBanner extends StatelessWidget {
this.backgroundColor,
this.borderRadius,
this.padding,
+ this.iconColor,
+ this.titleColor,
+ this.descriptionColor,
+ this.action,
+ this.leading,
});
/// The icon to display on the left side.
- /// Defaults to null. The icon will be rendered with primary color and 24pt size.
+ /// Ignored when [leading] is provided.
final IconData? icon;
+ /// Custom color for the icon. Defaults to [UiColors.primary].
+ final Color? iconColor;
+
/// The title text to display.
final String title;
+ /// Custom color for the title text. Defaults to primary text color.
+ final Color? titleColor;
+
/// Optional description text to display below the title.
final String? description;
+ /// Custom color for the description text. Defaults to secondary text color.
+ final Color? descriptionColor;
+
/// The background color of the banner.
/// Defaults to [UiColors.primary] with 8% opacity.
final Color? backgroundColor;
@@ -39,6 +53,12 @@ class UiNoticeBanner extends StatelessWidget {
/// Defaults to [UiConstants.space4] on all sides.
final EdgeInsetsGeometry? padding;
+ /// Optional action widget displayed on the right side of the banner.
+ final Widget? action;
+
+ /// Optional custom leading widget that replaces the icon when provided.
+ final Widget? leading;
+
@override
Widget build(BuildContext context) {
return Container(
@@ -50,8 +70,11 @@ class UiNoticeBanner extends StatelessWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- if (icon != null) ...[
- Icon(icon, color: UiColors.primary, size: 24),
+ if (leading != null) ...[
+ leading!,
+ const SizedBox(width: UiConstants.space3),
+ ] else if (icon != null) ...[
+ Icon(icon, color: iconColor ?? UiColors.primary, size: 24),
const SizedBox(width: UiConstants.space3),
],
Expanded(
@@ -60,15 +83,21 @@ class UiNoticeBanner extends StatelessWidget {
children: [
Text(
title,
- style: UiTypography.body2m.textPrimary,
+ style: UiTypography.body2b.copyWith(color: titleColor),
),
if (description != null) ...[
const SizedBox(height: 2),
Text(
description!,
- style: UiTypography.body2r.textSecondary,
+ style: UiTypography.body3r.copyWith(
+ color: descriptionColor,
+ ),
),
],
+ if (action != null) ...[
+ const SizedBox(height: UiConstants.space2),
+ action!,
+ ],
],
),
),
diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart
index 87b22493..c98147f3 100644
--- a/apps/mobile/packages/domain/lib/krow_domain.dart
+++ b/apps/mobile/packages/domain/lib/krow_domain.dart
@@ -14,6 +14,10 @@ export 'src/core/services/api_services/file_visibility.dart';
// Device
export 'src/core/services/device/base_device_service.dart';
+export 'src/core/services/device/location_permission_status.dart';
+
+// Models
+export 'src/core/models/device_location.dart';
// Users & Membership
export 'src/entities/users/user.dart';
diff --git a/apps/mobile/packages/domain/lib/src/core/models/device_location.dart b/apps/mobile/packages/domain/lib/src/core/models/device_location.dart
new file mode 100644
index 00000000..0f83b3b7
--- /dev/null
+++ b/apps/mobile/packages/domain/lib/src/core/models/device_location.dart
@@ -0,0 +1,27 @@
+import 'package:equatable/equatable.dart';
+
+/// Represents a geographic location obtained from the device.
+class DeviceLocation extends Equatable {
+ /// Latitude in degrees.
+ final double latitude;
+
+ /// Longitude in degrees.
+ final double longitude;
+
+ /// Estimated horizontal accuracy in meters.
+ final double accuracy;
+
+ /// 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