Merge pull request #651 from Oloodi/599-sred-research-develop-adaptive-battery-optimized-user-location-tracking-fe

feat(clock_in): Geofence validation, background tracking & feature restructuring
This commit is contained in:
Achintha Isuru
2026-03-17 12:19:20 -04:00
committed by GitHub
108 changed files with 5521 additions and 1682 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<example>\\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<commentary>\\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</commentary>\\n</example>\\n\\n<example>\\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<commentary>\\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</commentary>\\n</example>\\n\\n<example>\\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<commentary>\\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</commentary>\\n</example>"
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.<query>().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<T>()` instead of `ReadContext(context).read<T>()`
## 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:
<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 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.</description>
<when_to_save>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.</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]
</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 and other forms of persistence
Memory is one of several persistence mechanisms available to you as you assist the user in a given conversation. The distinction is often that memory can be recalled in future conversations and should not be used for persisting information that is only useful within the scope of the current conversation.
- When to use or update a plan instead of memory: If you are about to start a non-trivial implementation task and would like to reach alignment with the user on your approach you should use a Plan rather than saving this information to memory. Similarly, if you already have a plan within the conversation and you have changed your approach persist that change by updating the plan rather than saving a memory.
- When to use or update tasks instead of memory: When you need to break your work in current conversation into discrete steps or keep track of your progress use tasks instead of saving to memory. Tasks are great for persisting information about the work that needs to be done in the current conversation, but memory should be reserved for information that will be useful in future conversations.
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
## MEMORY.md
Your MEMORY.md is currently empty. When you save new memories, they will appear here.

View File

@@ -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):
</svg>
```
## 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

View File

@@ -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 = "../.."
}

View File

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

View File

@@ -30,12 +30,30 @@
@import firebase_core;
#endif
#if __has_include(<flutter_local_notifications/FlutterLocalNotificationsPlugin.h>)
#import <flutter_local_notifications/FlutterLocalNotificationsPlugin.h>
#else
@import flutter_local_notifications;
#endif
#if __has_include(<geolocator_apple/GeolocatorPlugin.h>)
#import <geolocator_apple/GeolocatorPlugin.h>
#else
@import geolocator_apple;
#endif
#if __has_include(<image_picker_ios/FLTImagePickerPlugin.h>)
#import <image_picker_ios/FLTImagePickerPlugin.h>
#else
@import image_picker_ios;
#endif
#if __has_include(<package_info_plus/FPPPackageInfoPlusPlugin.h>)
#import <package_info_plus/FPPPackageInfoPlusPlugin.h>
#else
@import package_info_plus;
#endif
#if __has_include(<record_ios/RecordIosPlugin.h>)
#import <record_ios/RecordIosPlugin.h>
#else
@@ -54,6 +72,12 @@
@import url_launcher_ios;
#endif
#if __has_include(<workmanager_apple/WorkmanagerPlugin.h>)
#import <workmanager_apple/WorkmanagerPlugin.h>
#else
@import workmanager_apple;
#endif
@implementation GeneratedPluginRegistrant
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)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

View File

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

View File

@@ -9,6 +9,7 @@
#include <file_selector_windows/file_selector_windows.h>
#include <firebase_auth/firebase_auth_plugin_c_api.h>
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <geolocator_windows/geolocator_windows.h>
#include <record_windows/record_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
@@ -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(

View File

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

View File

@@ -46,6 +46,7 @@ android {
ndkVersion = flutter.ndkVersion
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
@@ -126,6 +127,10 @@ afterEvaluate {
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
flutter {
source = "../.."
}

View File

@@ -1,4 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:label="@string/app_name"
android:name="${applicationName}"

View File

@@ -35,6 +35,11 @@ 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) {
@@ -55,16 +60,16 @@ public final class GeneratedPluginRegistrant {
} 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) {
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.baseflow.permissionhandler.PermissionHandlerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin permission_handler_android, com.baseflow.permissionhandler.PermissionHandlerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.llfbandit.record.RecordPlugin());
} catch (Exception e) {
@@ -80,5 +85,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);
}
}
}

View File

@@ -30,6 +30,12 @@
@import firebase_core;
#endif
#if __has_include(<flutter_local_notifications/FlutterLocalNotificationsPlugin.h>)
#import <flutter_local_notifications/FlutterLocalNotificationsPlugin.h>
#else
@import flutter_local_notifications;
#endif
#if __has_include(<geolocator_apple/GeolocatorPlugin.h>)
#import <geolocator_apple/GeolocatorPlugin.h>
#else
@@ -48,10 +54,10 @@
@import image_picker_ios;
#endif
#if __has_include(<permission_handler_apple/PermissionHandlerPlugin.h>)
#import <permission_handler_apple/PermissionHandlerPlugin.h>
#if __has_include(<package_info_plus/FPPPackageInfoPlusPlugin.h>)
#import <package_info_plus/FPPPackageInfoPlusPlugin.h>
#else
@import permission_handler_apple;
@import package_info_plus;
#endif
#if __has_include(<record_ios/RecordIosPlugin.h>)
@@ -72,6 +78,12 @@
@import url_launcher_ios;
#endif
#if __has_include(<workmanager_apple/WorkmanagerPlugin.h>)
#import <workmanager_apple/WorkmanagerPlugin.h>
#else
@import workmanager_apple;
#endif
@implementation GeneratedPluginRegistrant
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
@@ -79,13 +91,15 @@
[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"]];
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
[WorkmanagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"WorkmanagerPlugin"]];
}
@end

View File

@@ -45,6 +45,14 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSLocationWhenInUseUsageDescription</key>
<string>We need your location to verify you are at your assigned workplace for clock-in.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We need your location to verify you remain at your assigned workplace during your shift.</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>We need your location to verify you remain at your assigned workplace during your shift.</string>
<key>UIBackgroundModes</key>
<array><string>location</string></array>
<key>DART_DEFINES</key>
<string>$(DART_DEFINES)</string>
</dict>

View File

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

View File

@@ -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 url_launcher_macos
@@ -21,7 +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"))

View File

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

View File

@@ -10,7 +10,6 @@
#include <firebase_auth/firebase_auth_plugin_c_api.h>
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <geolocator_windows/geolocator_windows.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <record_windows/record_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
@@ -23,8 +22,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
GeolocatorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GeolocatorWindows"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
RecordWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(

View File

@@ -7,12 +7,12 @@ list(APPEND FLUTTER_PLUGIN_LIST
firebase_auth
firebase_core
geolocator_windows
permission_handler_windows
record_windows
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
flutter_local_notifications_windows
)
set(PLUGIN_BUNDLED_LIBRARIES)

View File

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

View File

@@ -48,5 +48,13 @@ class CoreModule extends Module {
apiUploadService: i.get<FileUploadService>(),
),
);
// 6. Register Geofence Device Services
i.addLazySingleton<LocationService>(() => const LocationService());
i.addLazySingleton<NotificationService>(() => NotificationService());
i.addLazySingleton<StorageService>(() => StorageService());
i.addLazySingleton<BackgroundTaskService>(
() => const BackgroundTaskService(),
);
}
}

View File

@@ -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<void> initialize(Function callbackDispatcher) async {
return action(() async {
await Workmanager().initialize(callbackDispatcher);
});
}
/// Registers a periodic background task with the given [frequency].
Future<void> registerPeriodicTask({
required String uniqueName,
required String taskName,
Duration frequency = const Duration(minutes: 15),
Map<String, dynamic>? inputData,
}) async {
return action(() async {
await Workmanager().registerPeriodicTask(
uniqueName,
taskName,
frequency: frequency,
inputData: inputData,
existingWorkPolicy: ExistingPeriodicWorkPolicy.replace,
);
});
}
/// Registers a one-off background task.
Future<void> registerOneOffTask({
required String uniqueName,
required String taskName,
Map<String, dynamic>? inputData,
}) async {
return action(() async {
await Workmanager().registerOneOffTask(
uniqueName,
taskName,
inputData: inputData,
);
});
}
/// Cancels a registered task by its [uniqueName].
Future<void> cancelByUniqueName(String uniqueName) async {
return action(() => Workmanager().cancelByUniqueName(uniqueName));
}
/// Cancels all registered background tasks.
Future<void> 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<bool> Function(String task, Map<String, dynamic>? inputData)
callback,
) {
Workmanager().executeTask(callback);
}
}

View File

@@ -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<LocationPermissionStatus> 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<LocationPermissionStatus> 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<DeviceLocation> 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<DeviceLocation> watchLocation({int distanceFilter = 10}) {
return Geolocator.getPositionStream(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: distanceFilter,
),
).map(_toDeviceLocation);
}
/// Whether device location services are currently enabled.
Future<bool> isServiceEnabled() async {
return action(() => Geolocator.isLocationServiceEnabled());
}
/// Stream that emits when location service status changes.
///
/// Emits `true` when enabled, `false` when disabled.
Stream<bool> get onServiceStatusChanged {
return Geolocator.getServiceStatusStream().map(
(ServiceStatus status) => status == ServiceStatus.enabled,
);
}
/// Opens the app settings page for the user to manually grant permissions.
Future<bool> openAppSettings() async {
return action(() => Geolocator.openAppSettings());
}
/// Opens the device location settings page.
Future<bool> 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,
);
}
}

View File

@@ -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<void> 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<void> _ensureInitialized() async {
if (!_initialized) await initialize();
}
/// Displays a local notification with the given [title] and [body].
Future<void> 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<void> cancelNotification(int id) async {
return action(() => _plugin.cancel(id: id));
}
/// Cancels all active notifications.
Future<void> cancelAll() async {
return action(() => _plugin.cancelAll());
}
}

View File

@@ -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<SharedPreferences> get _preferences async {
_prefs ??= await SharedPreferences.getInstance();
return _prefs!;
}
/// Retrieves a string value for the given [key].
Future<String?> 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<bool> 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<double?> 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<bool> 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<bool?> 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<bool> 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<bool> remove(String key) async {
return action(() async {
final SharedPreferences prefs = await _preferences;
return prefs.remove(key);
});
}
/// Clears all stored values.
Future<bool> clear() async {
return action(() async {
final SharedPreferences prefs = await _preferences;
return prefs.clear();
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -428,9 +428,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

View File

@@ -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: <Widget>[
if (icon != null) ...<Widget>[
Icon(icon, color: UiColors.primary, size: 24),
if (leading != null) ...<Widget>[
leading!,
const SizedBox(width: UiConstants.space3),
] else if (icon != null) ...<Widget>[
Icon(icon, color: iconColor ?? UiColors.primary, size: 24),
const SizedBox(width: UiConstants.space3),
],
Expanded(
@@ -60,15 +83,21 @@ class UiNoticeBanner extends StatelessWidget {
children: <Widget>[
Text(
title,
style: UiTypography.body2m.textPrimary,
style: UiTypography.body2b.copyWith(color: titleColor),
),
if (description != null) ...<Widget>[
const SizedBox(height: 2),
Text(
description!,
style: UiTypography.body2r.textSecondary,
style: UiTypography.body3r.copyWith(
color: descriptionColor,
),
),
],
if (action != null) ...<Widget>[
const SizedBox(height: UiConstants.space2),
action!,
],
],
),
),

View File

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

View File

@@ -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<Object?> get props => [latitude, longitude, accuracy, timestamp];
}

View File

@@ -0,0 +1,17 @@
/// Represents the current state of location permission granted by the user.
enum LocationPermissionStatus {
/// Full location access granted.
granted,
/// Location access granted only while the app is in use.
whileInUse,
/// Location permission was denied by the user.
denied,
/// Location permission was permanently denied by the user.
deniedForever,
/// Device location services are disabled.
serviceDisabled,
}

View File

@@ -26,11 +26,12 @@ class HubAddressAutocomplete extends StatelessWidget {
Widget build(BuildContext context) {
return GooglePlaceAutoCompleteTextField(
textEditingController: controller,
boxDecoration: null,
focusNode: focusNode,
inputDecoration: decoration ?? const InputDecoration(),
googleAPIKey: AppConfig.googleMapsApiKey,
debounceTime: 500,
countries: HubsConstants.supportedCountries,
//countries: HubsConstants.supportedCountries,
isLatLngRequired: true,
getPlaceDetailWithLatLng: (Prediction prediction) {
onSelected?.call(prediction);

View File

@@ -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
@@ -19,6 +22,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"))
}

View File

@@ -9,6 +9,7 @@
#include <file_selector_windows/file_selector_windows.h>
#include <firebase_auth/firebase_auth_plugin_c_api.h>
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <geolocator_windows/geolocator_windows.h>
#include <record_windows/record_windows_plugin_c_api.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
@@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
GeolocatorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GeolocatorWindows"));
RecordWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
}

View File

@@ -6,10 +6,12 @@ list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
firebase_auth
firebase_core
geolocator_windows
record_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
flutter_local_notifications_windows
)
set(PLUGIN_BUNDLED_LIBRARIES)

View File

@@ -183,12 +183,12 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface {
final fdc.Timestamp checkInTs = _service.toTimestamp(DateTime.now());
await _service.run(() => _service.connector
await _service.connector
.updateApplicationStatus(
id: app!.id,
id: app.id,
)
.checkInTime(checkInTs)
.execute());
.execute();
_activeApplicationId = app.id;
return getAttendanceStatus();
@@ -210,9 +210,9 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface {
}
final fdc.QueryResult<dc.GetApplicationByIdData,
dc.GetApplicationByIdVariables> appResult =
await _service.run(() => _service.connector
await _service.connector
.getApplicationById(id: targetAppId)
.execute());
.execute();
final dc.GetApplicationByIdApplication? app = appResult.data.application;
if (app == null) {
@@ -222,12 +222,12 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface {
throw Exception('No active shift found to clock out');
}
await _service.run(() => _service.connector
await _service.connector
.updateApplicationStatus(
id: targetAppId,
)
.checkOutTime(_service.toTimestamp(DateTime.now()))
.execute());
.execute();
return getAttendanceStatus();
});

View File

@@ -0,0 +1,194 @@
// ignore_for_file: avoid_print
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
/// Top-level callback dispatcher for background geofence tasks.
///
/// Must be a top-level function because workmanager executes it in a separate
/// isolate where the DI container is not available. Core services are
/// instantiated directly since they are simple wrappers.
///
/// Note: [Workmanager.executeTask] is kept because [BackgroundTaskService] does
/// not expose an equivalent callback-registration API. The `workmanager` import
/// is retained solely for this entry-point pattern.
@pragma('vm:entry-point')
void backgroundGeofenceDispatcher() {
const BackgroundTaskService().executeTask(
(String task, Map<String, dynamic>? inputData) async {
print('[BackgroundGeofence] Task triggered: $task');
print('[BackgroundGeofence] Input data: $inputData');
print(
'[BackgroundGeofence] Timestamp: ${DateTime.now().toIso8601String()}',
);
final double? targetLat = inputData?['targetLat'] as double?;
final double? targetLng = inputData?['targetLng'] as double?;
final String? shiftId = inputData?['shiftId'] as String?;
print(
'[BackgroundGeofence] Target: lat=$targetLat, lng=$targetLng, '
'shiftId=$shiftId',
);
if (targetLat == null || targetLng == null) {
print(
'[BackgroundGeofence] Missing target coordinates, skipping check',
);
return true;
}
try {
const LocationService locationService = LocationService();
final DeviceLocation location = await locationService.getCurrentLocation();
print(
'[BackgroundGeofence] Current position: '
'lat=${location.latitude}, lng=${location.longitude}',
);
final double distance = calculateDistance(
location.latitude,
location.longitude,
targetLat,
targetLng,
);
print(
'[BackgroundGeofence] Distance from target: ${distance.round()}m',
);
if (distance > BackgroundGeofenceService.geofenceRadiusMeters) {
print(
'[BackgroundGeofence] Worker is outside geofence '
'(${distance.round()}m > '
'${BackgroundGeofenceService.geofenceRadiusMeters.round()}m), '
'showing notification',
);
final String title = inputData?['leftGeofenceTitle'] as String? ??
"You've Left the Workplace";
final String body = inputData?['leftGeofenceBody'] as String? ??
'You appear to be more than 500m from your shift location.';
final NotificationService notificationService =
NotificationService();
await notificationService.showNotification(
id: BackgroundGeofenceService.leftGeofenceNotificationId,
title: title,
body: body,
);
} else {
print(
'[BackgroundGeofence] Worker is within geofence '
'(${distance.round()}m <= '
'${BackgroundGeofenceService.geofenceRadiusMeters.round()}m)',
);
}
} catch (e) {
print('[BackgroundGeofence] Error during background check: $e');
}
print('[BackgroundGeofence] Background check completed');
return true;
},
);
}
/// Service that manages periodic background geofence checks while clocked in.
///
/// Handles scheduling and cancelling background tasks only. Notification
/// delivery is handled by [ClockInNotificationService]. The background isolate
/// logic lives in the top-level [backgroundGeofenceDispatcher] function above.
class BackgroundGeofenceService {
/// Creates a [BackgroundGeofenceService] instance.
BackgroundGeofenceService({
required BackgroundTaskService backgroundTaskService,
required StorageService storageService,
}) : _backgroundTaskService = backgroundTaskService,
_storageService = storageService;
/// The core background task service for scheduling periodic work.
final BackgroundTaskService _backgroundTaskService;
/// The core storage service for persisting geofence target data.
final StorageService _storageService;
/// Storage key for the target latitude.
static const String _keyTargetLat = 'geofence_target_lat';
/// Storage key for the target longitude.
static const String _keyTargetLng = 'geofence_target_lng';
/// Storage key for the shift identifier.
static const String _keyShiftId = 'geofence_shift_id';
/// Storage key for the active tracking flag.
static const String _keyTrackingActive = 'geofence_tracking_active';
/// Unique task name for the periodic background check.
static const String taskUniqueName = 'geofence_background_check';
/// Task name identifier for the workmanager callback.
static const String taskName = 'geofenceCheck';
/// Notification ID for left-geofence warnings.
///
/// Kept here because the top-level [backgroundGeofenceDispatcher] references
/// it directly (background isolate has no DI access).
static const int leftGeofenceNotificationId = 2;
/// Geofence radius in meters.
static const double geofenceRadiusMeters = 500;
/// Starts periodic 15-minute background geofence checks.
///
/// Called after a successful clock-in. Persists the target coordinates
/// and passes localized notification strings via [inputData] so the
/// background isolate can display them without DI.
Future<void> startBackgroundTracking({
required double targetLat,
required double targetLng,
required String shiftId,
required String leftGeofenceTitle,
required String leftGeofenceBody,
}) async {
await Future.wait(<Future<bool>>[
_storageService.setDouble(_keyTargetLat, targetLat),
_storageService.setDouble(_keyTargetLng, targetLng),
_storageService.setString(_keyShiftId, shiftId),
_storageService.setBool(_keyTrackingActive, true),
]);
await _backgroundTaskService.registerPeriodicTask(
uniqueName: taskUniqueName,
taskName: taskName,
frequency: const Duration(minutes: 15),
inputData: <String, dynamic>{
'targetLat': targetLat,
'targetLng': targetLng,
'shiftId': shiftId,
'leftGeofenceTitle': leftGeofenceTitle,
'leftGeofenceBody': leftGeofenceBody,
},
);
}
/// Stops background geofence checks and clears persisted data.
///
/// Called after clock-out or when the shift ends.
Future<void> stopBackgroundTracking() async {
await _backgroundTaskService.cancelByUniqueName(taskUniqueName);
await Future.wait(<Future<bool>>[
_storageService.remove(_keyTargetLat),
_storageService.remove(_keyTargetLng),
_storageService.remove(_keyShiftId),
_storageService.setBool(_keyTrackingActive, false),
]);
}
/// Whether background tracking is currently active.
Future<bool> get isTrackingActive async {
final bool? active = await _storageService.getBool(_keyTrackingActive);
return active ?? false;
}
}

View File

@@ -0,0 +1,61 @@
import 'package:krow_core/core.dart';
/// Service responsible for displaying clock-in related local notifications.
///
/// Encapsulates notification logic extracted from [BackgroundGeofenceService]
/// so that geofence tracking and user-facing notifications have separate
/// responsibilities.
class ClockInNotificationService {
/// Creates a [ClockInNotificationService] instance.
const ClockInNotificationService({
required NotificationService notificationService,
}) : _notificationService = notificationService;
/// The underlying core notification service.
final NotificationService _notificationService;
/// Notification ID for clock-in greeting notifications.
static const int _clockInNotificationId = 1;
/// Notification ID for left-geofence warnings.
static const int leftGeofenceNotificationId = 2;
/// Notification ID for clock-out notifications.
static const int _clockOutNotificationId = 3;
/// Shows a greeting notification after successful clock-in.
Future<void> showClockInGreeting({
required String title,
required String body,
}) async {
await _notificationService.showNotification(
title: title,
body: body,
id: _clockInNotificationId,
);
}
/// Shows a notification when the worker clocks out.
Future<void> showClockOutNotification({
required String title,
required String body,
}) async {
await _notificationService.showNotification(
title: title,
body: body,
id: _clockOutNotificationId,
);
}
/// Shows a notification when the worker leaves the geofence.
Future<void> showLeftGeofenceNotification({
required String title,
required String body,
}) async {
await _notificationService.showNotification(
title: title,
body: body,
id: leftGeofenceNotificationId,
);
}
}

View File

@@ -0,0 +1,114 @@
import 'dart:async';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/models/geofence_result.dart';
import '../../domain/services/geofence_service_interface.dart';
/// Implementation of [GeofenceServiceInterface] using core [LocationService].
class GeofenceServiceImpl implements GeofenceServiceInterface {
/// Creates a [GeofenceServiceImpl] instance.
GeofenceServiceImpl({
required LocationService locationService,
this.debugAlwaysInRange = false,
}) : _locationService = locationService;
/// The core location service for device GPS access.
final LocationService _locationService;
/// When true, always reports the device as within radius. For dev builds.
final bool debugAlwaysInRange;
/// Average walking speed in meters per minute for ETA estimation.
static const double _walkingSpeedMetersPerMinute = 80;
@override
Future<LocationPermissionStatus> ensurePermission() {
return _locationService.checkAndRequestPermission();
}
@override
Future<LocationPermissionStatus> requestAlwaysPermission() {
return _locationService.requestAlwaysPermission();
}
@override
Stream<GeofenceResult> watchGeofence({
required double targetLat,
required double targetLng,
double radiusMeters = 500,
}) {
return _locationService.watchLocation(distanceFilter: 10).map(
(DeviceLocation location) => _buildResult(
location: location,
targetLat: targetLat,
targetLng: targetLng,
radiusMeters: radiusMeters,
),
);
}
@override
Future<GeofenceResult?> checkGeofenceWithTimeout({
required double targetLat,
required double targetLng,
double radiusMeters = 500,
Duration timeout = const Duration(seconds: 30),
}) async {
try {
final DeviceLocation location =
await _locationService.getCurrentLocation().timeout(timeout);
return _buildResult(
location: location,
targetLat: targetLat,
targetLng: targetLng,
radiusMeters: radiusMeters,
);
} on TimeoutException {
return null;
}
}
@override
Stream<bool> watchServiceStatus() {
return _locationService.onServiceStatusChanged;
}
@override
Future<void> openAppSettings() async {
await _locationService.openAppSettings();
}
@override
Future<void> openLocationSettings() async {
await _locationService.openLocationSettings();
}
/// Builds a [GeofenceResult] from a location and target coordinates.
GeofenceResult _buildResult({
required DeviceLocation location,
required double targetLat,
required double targetLng,
required double radiusMeters,
}) {
final double distance = calculateDistance(
location.latitude,
location.longitude,
targetLat,
targetLng,
);
final bool isWithin = debugAlwaysInRange || distance <= radiusMeters;
final int eta =
isWithin ? 0 : (distance / _walkingSpeedMetersPerMinute).round();
return GeofenceResult(
distanceMeters: distance,
isWithinRadius: isWithin,
estimatedEtaMinutes: eta,
location: location,
);
}
}

View File

@@ -0,0 +1,33 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
/// Result of a geofence proximity check.
class GeofenceResult extends Equatable {
/// Creates a [GeofenceResult] instance.
const GeofenceResult({
required this.distanceMeters,
required this.isWithinRadius,
required this.estimatedEtaMinutes,
required this.location,
});
/// Distance from the target location in meters.
final double distanceMeters;
/// Whether the device is within the allowed geofence radius.
final bool isWithinRadius;
/// Estimated time of arrival in minutes if outside the radius.
final int estimatedEtaMinutes;
/// The device location at the time of the check.
final DeviceLocation location;
@override
List<Object?> get props => <Object?>[
distanceMeters,
isWithinRadius,
estimatedEtaMinutes,
location,
];
}

View File

@@ -0,0 +1,36 @@
import 'package:krow_domain/krow_domain.dart';
import '../models/geofence_result.dart';
/// Interface for geofence proximity verification.
abstract class GeofenceServiceInterface {
/// Checks and requests location permission.
Future<LocationPermissionStatus> ensurePermission();
/// Requests upgrade to "Always" permission for background access.
Future<LocationPermissionStatus> requestAlwaysPermission();
/// Emits geofence results as the device moves relative to a target.
Stream<GeofenceResult> watchGeofence({
required double targetLat,
required double targetLng,
double radiusMeters = 500,
});
/// Checks geofence once with a timeout. Returns null if GPS times out.
Future<GeofenceResult?> checkGeofenceWithTimeout({
required double targetLat,
required double targetLng,
double radiusMeters = 500,
Duration timeout = const Duration(seconds: 30),
});
/// Stream of location service status changes (enabled/disabled).
Stream<bool> watchServiceStatus();
/// Opens the app settings page.
Future<void> openAppSettings();
/// Opens the device location settings page.
Future<void> openLocationSettings();
}

View File

@@ -0,0 +1,55 @@
import 'package:equatable/equatable.dart';
/// Immutable input context carrying all data needed for clock-in validation.
///
/// Constructed by the presentation layer and passed through the validation
/// pipeline so that each validator can inspect the fields it cares about.
class ClockInValidationContext extends Equatable {
/// Creates a [ClockInValidationContext].
const ClockInValidationContext({
required this.isCheckingIn,
this.shiftStartTime,
this.shiftEndTime,
this.hasCoordinates = false,
this.isLocationVerified = false,
this.isLocationTimedOut = false,
this.isGeofenceOverridden = false,
this.overrideNotes,
});
/// Whether this is a clock-in attempt (`true`) or clock-out (`false`).
final bool isCheckingIn;
/// The scheduled start time of the shift, if known.
final DateTime? shiftStartTime;
/// The scheduled end time of the shift, if known.
final DateTime? shiftEndTime;
/// Whether the shift's venue has latitude/longitude coordinates.
final bool hasCoordinates;
/// Whether the device location has been verified against the geofence.
final bool isLocationVerified;
/// Whether the location check timed out before verification completed.
final bool isLocationTimedOut;
/// Whether the worker explicitly overrode the geofence via justification.
final bool isGeofenceOverridden;
/// Optional notes provided when overriding or timing out.
final String? overrideNotes;
@override
List<Object?> get props => <Object?>[
isCheckingIn,
shiftStartTime,
shiftEndTime,
hasCoordinates,
isLocationVerified,
isLocationTimedOut,
isGeofenceOverridden,
overrideNotes,
];
}

View File

@@ -0,0 +1,24 @@
import 'package:equatable/equatable.dart';
/// The outcome of a single validation step in the clock-in pipeline.
///
/// Use the named constructors [ClockInValidationResult.valid] and
/// [ClockInValidationResult.invalid] to create instances.
class ClockInValidationResult extends Equatable {
/// Creates a passing validation result.
const ClockInValidationResult.valid()
: isValid = true,
errorKey = null;
/// Creates a failing validation result with the given [errorKey].
const ClockInValidationResult.invalid(this.errorKey) : isValid = false;
/// Whether the validation passed.
final bool isValid;
/// A localization key describing the validation failure, or `null` if valid.
final String? errorKey;
@override
List<Object?> get props => <Object?>[isValid, errorKey];
}

View File

@@ -0,0 +1,11 @@
import '../clock_in_validation_context.dart';
import '../clock_in_validation_result.dart';
/// Abstract interface for a single step in the clock-in validation pipeline.
///
/// Implementations inspect the [ClockInValidationContext] and return a
/// [ClockInValidationResult] indicating whether the check passed or failed.
abstract class ClockInValidator {
/// Validates the given [context] and returns the result.
ClockInValidationResult validate(ClockInValidationContext context);
}

View File

@@ -0,0 +1,27 @@
import '../clock_in_validation_context.dart';
import '../clock_in_validation_result.dart';
import 'clock_in_validator.dart';
/// Runs a list of [ClockInValidator]s in order, short-circuiting on first failure.
///
/// This implements the composite pattern to chain multiple validation rules
/// into a single pipeline. Validators are executed sequentially and the first
/// failing result is returned immediately.
class CompositeClockInValidator implements ClockInValidator {
/// Creates a [CompositeClockInValidator] with the given [validators].
const CompositeClockInValidator(this.validators);
/// The ordered list of validators to execute.
final List<ClockInValidator> validators;
/// Runs each validator in order. Returns the first failing result,
/// or [ClockInValidationResult.valid] if all pass.
@override
ClockInValidationResult validate(ClockInValidationContext context) {
for (final ClockInValidator validator in validators) {
final ClockInValidationResult result = validator.validate(context);
if (!result.isValid) return result;
}
return const ClockInValidationResult.valid();
}
}

View File

@@ -0,0 +1,35 @@
import '../clock_in_validation_context.dart';
import '../clock_in_validation_result.dart';
import 'clock_in_validator.dart';
/// Validates that geofence requirements are satisfied before clock-in.
///
/// Only applies when checking in to a shift that has venue coordinates.
/// If the shift has no coordinates or this is a clock-out, validation passes.
///
/// Logic extracted from [ClockInBloc._onCheckIn]:
/// - If the shift requires location verification but the geofence has not
/// confirmed proximity, has not timed out, and the worker has not
/// explicitly overridden via the justification modal, the attempt is rejected.
class GeofenceValidator implements ClockInValidator {
/// Creates a [GeofenceValidator].
const GeofenceValidator();
/// Returns invalid when clocking in to a location-based shift without
/// verified location, timeout, or explicit override.
@override
ClockInValidationResult validate(ClockInValidationContext context) {
// Only applies to clock-in for shifts with coordinates.
if (!context.isCheckingIn || !context.hasCoordinates) {
return const ClockInValidationResult.valid();
}
if (!context.isLocationVerified &&
!context.isLocationTimedOut &&
!context.isGeofenceOverridden) {
return const ClockInValidationResult.invalid('geofence_not_verified');
}
return const ClockInValidationResult.valid();
}
}

View File

@@ -0,0 +1,35 @@
import '../clock_in_validation_context.dart';
import '../clock_in_validation_result.dart';
import 'clock_in_validator.dart';
/// Validates that override notes are provided when required.
///
/// When the location check timed out or the geofence was explicitly overridden,
/// the worker must supply non-empty notes explaining why they are clocking in
/// without verified proximity.
///
/// Logic extracted from [ClockInBloc._onCheckIn] notes check.
class OverrideNotesValidator implements ClockInValidator {
/// Creates an [OverrideNotesValidator].
const OverrideNotesValidator();
/// Returns invalid if notes are required but missing or empty.
@override
ClockInValidationResult validate(ClockInValidationContext context) {
// Only applies to clock-in attempts.
if (!context.isCheckingIn) {
return const ClockInValidationResult.valid();
}
final bool notesRequired =
context.isLocationTimedOut || context.isGeofenceOverridden;
if (notesRequired &&
(context.overrideNotes == null ||
context.overrideNotes!.trim().isEmpty)) {
return const ClockInValidationResult.invalid('notes_required');
}
return const ClockInValidationResult.valid();
}
}

View File

@@ -0,0 +1,77 @@
import 'package:intl/intl.dart';
import 'package:staff_clock_in/src/domain/validators/clock_in_validation_context.dart';
import 'package:staff_clock_in/src/domain/validators/clock_in_validation_result.dart';
import 'clock_in_validator.dart';
/// Validates that the current time falls within the allowed window.
///
/// - For clock-in: the current time must be at most 15 minutes before the
/// shift start time.
/// - For clock-out: the current time must be at most 15 minutes before the
/// shift end time.
/// - If the relevant shift time is `null`, validation passes (don't block
/// when the time is unknown).
class TimeWindowValidator implements ClockInValidator {
/// Creates a [TimeWindowValidator].
const TimeWindowValidator();
/// The number of minutes before the shift time that the action is allowed.
static const int _earlyWindowMinutes = 15;
/// Returns invalid if the current time is too early for the action.
@override
ClockInValidationResult validate(ClockInValidationContext context) {
if (context.isCheckingIn) {
return _validateClockIn(context);
}
return _validateClockOut(context);
}
/// Validates the clock-in time window against [shiftStartTime].
ClockInValidationResult _validateClockIn(ClockInValidationContext context) {
final DateTime? shiftStart = context.shiftStartTime;
if (shiftStart == null) {
return const ClockInValidationResult.valid();
}
final DateTime windowStart = shiftStart.subtract(
const Duration(minutes: _earlyWindowMinutes),
);
if (DateTime.now().isBefore(windowStart)) {
return const ClockInValidationResult.invalid('too_early_clock_in');
}
return const ClockInValidationResult.valid();
}
/// Validates the clock-out time window against [shiftEndTime].
ClockInValidationResult _validateClockOut(ClockInValidationContext context) {
final DateTime? shiftEnd = context.shiftEndTime;
if (shiftEnd == null) {
return const ClockInValidationResult.valid();
}
final DateTime windowStart = shiftEnd.subtract(
const Duration(minutes: _earlyWindowMinutes),
);
if (DateTime.now().isBefore(windowStart)) {
return const ClockInValidationResult.invalid('too_early_clock_out');
}
return const ClockInValidationResult.valid();
}
/// Returns the formatted earliest allowed time for the given [shiftTime].
///
/// The result is a 12-hour string such as "8:45 AM". Presentation code
/// can call this directly without depending on Flutter's [BuildContext].
static String getAvailabilityTime(DateTime shiftTime) {
final DateTime windowStart = shiftTime.subtract(
const Duration(minutes: _earlyWindowMinutes),
);
return DateFormat('h:mm a').format(windowStart);
}
}

View File

@@ -0,0 +1,405 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../../domain/arguments/clock_in_arguments.dart';
import '../../../domain/arguments/clock_out_arguments.dart';
import '../../../domain/usecases/clock_in_usecase.dart';
import '../../../domain/usecases/clock_out_usecase.dart';
import '../../../domain/usecases/get_attendance_status_usecase.dart';
import '../../../domain/usecases/get_todays_shift_usecase.dart';
import '../../../domain/validators/clock_in_validation_context.dart';
import '../../../domain/validators/clock_in_validation_result.dart';
import '../../../domain/validators/validators/composite_clock_in_validator.dart';
import '../../../domain/validators/validators/time_window_validator.dart';
import '../geofence/geofence_bloc.dart';
import '../geofence/geofence_event.dart';
import '../geofence/geofence_state.dart';
import 'clock_in_event.dart';
import 'clock_in_state.dart';
/// BLoC responsible for clock-in/clock-out operations and shift management.
///
/// Reads [GeofenceBloc] state directly to evaluate geofence conditions,
/// removing the need for the UI to bridge geofence fields into events.
/// Validation is delegated to [CompositeClockInValidator].
/// Background tracking lifecycle is managed here after successful
/// clock-in/clock-out, rather than in the UI layer.
class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
with BlocErrorHandler<ClockInState> {
/// Creates a [ClockInBloc] with the required use cases, geofence BLoC,
/// and validator.
ClockInBloc({
required GetTodaysShiftUseCase getTodaysShift,
required GetAttendanceStatusUseCase getAttendanceStatus,
required ClockInUseCase clockIn,
required ClockOutUseCase clockOut,
required GeofenceBloc geofenceBloc,
required CompositeClockInValidator validator,
}) : _getTodaysShift = getTodaysShift,
_getAttendanceStatus = getAttendanceStatus,
_clockIn = clockIn,
_clockOut = clockOut,
_geofenceBloc = geofenceBloc,
_validator = validator,
super(ClockInState(selectedDate: DateTime.now())) {
on<ClockInPageLoaded>(_onLoaded);
on<ShiftSelected>(_onShiftSelected);
on<DateSelected>(_onDateSelected);
on<CheckInRequested>(_onCheckIn);
on<CheckOutRequested>(_onCheckOut);
on<CheckInModeChanged>(_onModeChanged);
on<TimeWindowRefreshRequested>(_onTimeWindowRefresh);
}
final GetTodaysShiftUseCase _getTodaysShift;
final GetAttendanceStatusUseCase _getAttendanceStatus;
final ClockInUseCase _clockIn;
final ClockOutUseCase _clockOut;
/// Reference to [GeofenceBloc] for reading geofence state directly.
final GeofenceBloc _geofenceBloc;
/// Composite validator for clock-in preconditions.
final CompositeClockInValidator _validator;
/// Periodic timer that re-evaluates time window flags every 30 seconds
/// so the "too early" banner updates without user interaction.
Timer? _timeWindowTimer;
/// Loads today's shifts and the current attendance status.
Future<void> _onLoaded(
ClockInPageLoaded event,
Emitter<ClockInState> emit,
) async {
emit(state.copyWith(status: ClockInStatus.loading));
await handleError(
emit: emit.call,
action: () async {
final List<Shift> shifts = await _getTodaysShift();
final AttendanceStatus status = await _getAttendanceStatus();
Shift? selectedShift;
if (shifts.isNotEmpty) {
if (status.activeShiftId != null) {
try {
selectedShift =
shifts.firstWhere((Shift s) => s.id == status.activeShiftId);
} catch (_) {}
}
selectedShift ??= shifts.last;
}
final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(
selectedShift,
);
emit(state.copyWith(
status: ClockInStatus.success,
todayShifts: shifts,
selectedShift: selectedShift,
attendance: status,
isCheckInAllowed: timeFlags.isCheckInAllowed,
isCheckOutAllowed: timeFlags.isCheckOutAllowed,
checkInAvailabilityTime: timeFlags.checkInAvailabilityTime,
checkOutAvailabilityTime: timeFlags.checkOutAvailabilityTime,
));
// Start periodic timer so time-window banners auto-update.
_startTimeWindowTimer();
},
onError: (String errorKey) => state.copyWith(
status: ClockInStatus.failure,
errorMessage: errorKey,
),
);
}
/// Updates the currently selected shift and recomputes time window flags.
void _onShiftSelected(
ShiftSelected event,
Emitter<ClockInState> emit,
) {
final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(event.shift);
emit(state.copyWith(
selectedShift: event.shift,
isCheckInAllowed: timeFlags.isCheckInAllowed,
isCheckOutAllowed: timeFlags.isCheckOutAllowed,
checkInAvailabilityTime: timeFlags.checkInAvailabilityTime,
checkOutAvailabilityTime: timeFlags.checkOutAvailabilityTime,
));
}
/// Updates the selected date and re-fetches shifts.
///
/// Currently the repository always fetches today's shifts regardless of
/// the selected date. Re-loading ensures the UI stays in sync after a
/// date change.
// TODO(clock_in): Pass selected date to repository for date-based filtering.
Future<void> _onDateSelected(
DateSelected event,
Emitter<ClockInState> emit,
) async {
emit(state.copyWith(selectedDate: event.date));
await _onLoaded(ClockInPageLoaded(), emit);
}
/// Updates the check-in interaction mode.
void _onModeChanged(
CheckInModeChanged event,
Emitter<ClockInState> emit,
) {
emit(state.copyWith(checkInMode: event.mode));
}
/// Handles a clock-in request.
///
/// Reads geofence state directly from [_geofenceBloc] and builds a
/// [ClockInValidationContext] to run through the [_validator] pipeline.
/// On success, dispatches [BackgroundTrackingStarted] to [_geofenceBloc].
Future<void> _onCheckIn(
CheckInRequested event,
Emitter<ClockInState> emit,
) async {
// Clear previous error so repeated failures are always emitted as new states.
if (state.errorMessage != null) {
emit(state.copyWith(errorMessage: null));
}
final Shift? shift = state.selectedShift;
final GeofenceState geofenceState = _geofenceBloc.state;
final bool hasCoordinates =
shift != null && shift.latitude != null && shift.longitude != null;
// Build validation context from combined BLoC states.
final ClockInValidationContext validationContext = ClockInValidationContext(
isCheckingIn: true,
shiftStartTime: _tryParseDateTime(shift?.startTime),
shiftEndTime: _tryParseDateTime(shift?.endTime),
hasCoordinates: hasCoordinates,
isLocationVerified: geofenceState.isLocationVerified,
isLocationTimedOut: geofenceState.isLocationTimedOut,
isGeofenceOverridden: geofenceState.isGeofenceOverridden,
overrideNotes: event.notes,
);
final ClockInValidationResult validationResult =
_validator.validate(validationContext);
if (!validationResult.isValid) {
emit(state.copyWith(
status: ClockInStatus.failure,
errorMessage: validationResult.errorKey,
));
return;
}
emit(state.copyWith(status: ClockInStatus.actionInProgress));
await handleError(
emit: emit.call,
action: () async {
final AttendanceStatus newStatus = await _clockIn(
ClockInArguments(shiftId: event.shiftId, notes: event.notes),
);
emit(state.copyWith(
status: ClockInStatus.success,
attendance: newStatus,
));
// Start background tracking after successful clock-in.
_dispatchBackgroundTrackingStarted(
event: event,
activeShiftId: newStatus.activeShiftId,
);
},
onError: (String errorKey) => state.copyWith(
status: ClockInStatus.failure,
errorMessage: errorKey,
),
);
}
/// Handles a clock-out request.
///
/// On success, dispatches [BackgroundTrackingStopped] to [_geofenceBloc].
Future<void> _onCheckOut(
CheckOutRequested event,
Emitter<ClockInState> emit,
) async {
emit(state.copyWith(status: ClockInStatus.actionInProgress));
await handleError(
emit: emit.call,
action: () async {
final AttendanceStatus newStatus = await _clockOut(
ClockOutArguments(
notes: event.notes,
breakTimeMinutes: event.breakTimeMinutes ?? 0,
applicationId: state.attendance.activeApplicationId,
),
);
emit(state.copyWith(
status: ClockInStatus.success,
attendance: newStatus,
));
// Stop background tracking after successful clock-out.
_geofenceBloc.add(
BackgroundTrackingStopped(
clockOutTitle: event.clockOutTitle,
clockOutBody: event.clockOutBody,
),
);
},
onError: (String errorKey) => state.copyWith(
status: ClockInStatus.failure,
errorMessage: errorKey,
),
);
}
/// Re-evaluates time window flags for the currently selected shift.
///
/// Fired periodically by [_timeWindowTimer] so banners like "too early"
/// automatically disappear once the check-in window opens.
void _onTimeWindowRefresh(
TimeWindowRefreshRequested event,
Emitter<ClockInState> emit,
) {
if (state.status != ClockInStatus.success) return;
final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(
state.selectedShift,
);
emit(state.copyWith(
isCheckInAllowed: timeFlags.isCheckInAllowed,
isCheckOutAllowed: timeFlags.isCheckOutAllowed,
checkInAvailabilityTime: timeFlags.checkInAvailabilityTime,
clearCheckInAvailabilityTime: timeFlags.checkInAvailabilityTime == null,
checkOutAvailabilityTime: timeFlags.checkOutAvailabilityTime,
clearCheckOutAvailabilityTime:
timeFlags.checkOutAvailabilityTime == null,
));
}
/// Starts the periodic time-window refresh timer.
// TODO: Change this logic to more comprehensive logic based on the actual shift times instead of a fixed 30-second timer.
void _startTimeWindowTimer() {
_timeWindowTimer?.cancel();
_timeWindowTimer = Timer.periodic(
const Duration(seconds: 30),
(_) => add(const TimeWindowRefreshRequested()),
);
}
@override
Future<void> close() {
_timeWindowTimer?.cancel();
return super.close();
}
/// Safely parses a time string into a [DateTime], returning `null` on failure.
static DateTime? _tryParseDateTime(String? value) {
if (value == null || value.isEmpty) return null;
return DateTime.tryParse(value);
}
/// Computes time-window check-in/check-out flags for the given [shift].
///
/// Uses [TimeWindowValidator] so this business logic stays out of widgets.
static _TimeWindowFlags _computeTimeWindowFlags(Shift? shift) {
if (shift == null) {
return const _TimeWindowFlags();
}
const TimeWindowValidator validator = TimeWindowValidator();
final DateTime? shiftStart = _tryParseDateTime(shift.startTime);
final DateTime? shiftEnd = _tryParseDateTime(shift.endTime);
// Check-in window.
bool isCheckInAllowed = true;
String? checkInAvailabilityTime;
if (shiftStart != null) {
final ClockInValidationContext checkInCtx = ClockInValidationContext(
isCheckingIn: true,
shiftStartTime: shiftStart,
);
isCheckInAllowed = validator.validate(checkInCtx).isValid;
if (!isCheckInAllowed) {
checkInAvailabilityTime =
TimeWindowValidator.getAvailabilityTime(shiftStart);
}
}
// Check-out window.
bool isCheckOutAllowed = true;
String? checkOutAvailabilityTime;
if (shiftEnd != null) {
final ClockInValidationContext checkOutCtx = ClockInValidationContext(
isCheckingIn: false,
shiftEndTime: shiftEnd,
);
isCheckOutAllowed = validator.validate(checkOutCtx).isValid;
if (!isCheckOutAllowed) {
checkOutAvailabilityTime =
TimeWindowValidator.getAvailabilityTime(shiftEnd);
}
}
return _TimeWindowFlags(
isCheckInAllowed: isCheckInAllowed,
isCheckOutAllowed: isCheckOutAllowed,
checkInAvailabilityTime: checkInAvailabilityTime,
checkOutAvailabilityTime: checkOutAvailabilityTime,
);
}
/// Dispatches [BackgroundTrackingStarted] to [_geofenceBloc] if the
/// geofence has target coordinates.
void _dispatchBackgroundTrackingStarted({
required CheckInRequested event,
required String? activeShiftId,
}) {
final GeofenceState geofenceState = _geofenceBloc.state;
if (geofenceState.targetLat != null &&
geofenceState.targetLng != null &&
activeShiftId != null) {
_geofenceBloc.add(
BackgroundTrackingStarted(
shiftId: activeShiftId,
targetLat: geofenceState.targetLat!,
targetLng: geofenceState.targetLng!,
greetingTitle: event.clockInGreetingTitle,
greetingBody: event.clockInGreetingBody,
leftGeofenceTitle: event.leftGeofenceTitle,
leftGeofenceBody: event.leftGeofenceBody,
),
);
}
}
}
/// Internal value holder for time-window computation results.
class _TimeWindowFlags {
/// Creates a [_TimeWindowFlags] with default allowed values.
const _TimeWindowFlags({
this.isCheckInAllowed = true,
this.isCheckOutAllowed = true,
this.checkInAvailabilityTime,
this.checkOutAvailabilityTime,
});
/// Whether the time window currently allows check-in.
final bool isCheckInAllowed;
/// Whether the time window currently allows check-out.
final bool isCheckOutAllowed;
/// Formatted time when check-in becomes available, or `null`.
final String? checkInAvailabilityTime;
/// Formatted time when check-out becomes available, or `null`.
final String? checkOutAvailabilityTime;
}

View File

@@ -0,0 +1,129 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
/// Base class for all clock-in related events.
abstract class ClockInEvent extends Equatable {
const ClockInEvent();
@override
List<Object?> get props => <Object?>[];
}
/// Emitted when the clock-in page is first loaded.
class ClockInPageLoaded extends ClockInEvent {}
/// Emitted when the user selects a shift from the list.
class ShiftSelected extends ClockInEvent {
const ShiftSelected(this.shift);
/// The shift the user selected.
final Shift shift;
@override
List<Object?> get props => <Object?>[shift];
}
/// Emitted when the user picks a different date.
class DateSelected extends ClockInEvent {
const DateSelected(this.date);
/// The newly selected date.
final DateTime date;
@override
List<Object?> get props => <Object?>[date];
}
/// Emitted when the user requests to clock in.
///
/// Geofence state is read directly by the BLoC from [GeofenceBloc],
/// so this event only carries the shift ID, optional notes, and
/// notification strings for background tracking.
class CheckInRequested extends ClockInEvent {
const CheckInRequested({
required this.shiftId,
this.notes,
this.clockInGreetingTitle = '',
this.clockInGreetingBody = '',
this.leftGeofenceTitle = '',
this.leftGeofenceBody = '',
});
/// The ID of the shift to clock into.
final String shiftId;
/// Optional notes provided by the user (e.g. geofence override notes).
final String? notes;
/// Localized title for the clock-in greeting notification.
final String clockInGreetingTitle;
/// Localized body for the clock-in greeting notification.
final String clockInGreetingBody;
/// Localized title for the left-geofence background notification.
final String leftGeofenceTitle;
/// Localized body for the left-geofence background notification.
final String leftGeofenceBody;
@override
List<Object?> get props => <Object?>[
shiftId,
notes,
clockInGreetingTitle,
clockInGreetingBody,
leftGeofenceTitle,
leftGeofenceBody,
];
}
/// Emitted when the user requests to clock out.
class CheckOutRequested extends ClockInEvent {
const CheckOutRequested({
this.notes,
this.breakTimeMinutes,
this.clockOutTitle = '',
this.clockOutBody = '',
});
/// Optional notes provided by the user.
final String? notes;
/// Break time taken during the shift, in minutes.
final int? breakTimeMinutes;
/// Localized title for the clock-out notification.
final String clockOutTitle;
/// Localized body for the clock-out notification.
final String clockOutBody;
@override
List<Object?> get props => <Object?>[
notes,
breakTimeMinutes,
clockOutTitle,
clockOutBody,
];
}
/// Emitted when the user changes the check-in mode (e.g. swipe vs tap).
class CheckInModeChanged extends ClockInEvent {
const CheckInModeChanged(this.mode);
/// The new check-in mode identifier.
final String mode;
@override
List<Object?> get props => <Object?>[mode];
}
/// Periodically emitted by a timer to re-evaluate time window flags.
///
/// Ensures banners like "too early to check in" disappear once the
/// time window opens, without requiring user interaction.
class TimeWindowRefreshRequested extends ClockInEvent {
/// Creates a [TimeWindowRefreshRequested] event.
const TimeWindowRefreshRequested();
}

View File

@@ -0,0 +1,114 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
/// Represents the possible statuses of the clock-in page.
enum ClockInStatus { initial, loading, success, failure, actionInProgress }
/// State for the [ClockInBloc].
///
/// Contains today's shifts, the selected shift, attendance status,
/// and clock-in UI configuration. Location/geofence concerns are
/// managed separately by [GeofenceBloc].
class ClockInState extends Equatable {
const ClockInState({
this.status = ClockInStatus.initial,
this.todayShifts = const <Shift>[],
this.selectedShift,
this.attendance = const AttendanceStatus(),
required this.selectedDate,
this.checkInMode = 'swipe',
this.errorMessage,
this.isCheckInAllowed = true,
this.isCheckOutAllowed = true,
this.checkInAvailabilityTime,
this.checkOutAvailabilityTime,
});
/// Current page status.
final ClockInStatus status;
/// List of shifts scheduled for the selected date.
final List<Shift> todayShifts;
/// The shift currently selected by the user.
final Shift? selectedShift;
/// Current attendance/check-in status from the backend.
final AttendanceStatus attendance;
/// The date the user is viewing shifts for.
final DateTime selectedDate;
/// The current check-in interaction mode (e.g. 'swipe').
final String checkInMode;
/// Error message key for displaying failures.
final String? errorMessage;
/// Whether the time window allows the user to check in.
final bool isCheckInAllowed;
/// Whether the time window allows the user to check out.
final bool isCheckOutAllowed;
/// Formatted earliest time when check-in becomes available, or `null`.
final String? checkInAvailabilityTime;
/// Formatted earliest time when check-out becomes available, or `null`.
final String? checkOutAvailabilityTime;
/// Creates a copy of this state with the given fields replaced.
///
/// Use the `clearX` flags to explicitly set nullable fields to `null`,
/// since the `??` fallback otherwise prevents clearing.
ClockInState copyWith({
ClockInStatus? status,
List<Shift>? todayShifts,
Shift? selectedShift,
bool clearSelectedShift = false,
AttendanceStatus? attendance,
DateTime? selectedDate,
String? checkInMode,
String? errorMessage,
bool? isCheckInAllowed,
bool? isCheckOutAllowed,
String? checkInAvailabilityTime,
bool clearCheckInAvailabilityTime = false,
String? checkOutAvailabilityTime,
bool clearCheckOutAvailabilityTime = false,
}) {
return ClockInState(
status: status ?? this.status,
todayShifts: todayShifts ?? this.todayShifts,
selectedShift:
clearSelectedShift ? null : (selectedShift ?? this.selectedShift),
attendance: attendance ?? this.attendance,
selectedDate: selectedDate ?? this.selectedDate,
checkInMode: checkInMode ?? this.checkInMode,
errorMessage: errorMessage,
isCheckInAllowed: isCheckInAllowed ?? this.isCheckInAllowed,
isCheckOutAllowed: isCheckOutAllowed ?? this.isCheckOutAllowed,
checkInAvailabilityTime: clearCheckInAvailabilityTime
? null
: (checkInAvailabilityTime ?? this.checkInAvailabilityTime),
checkOutAvailabilityTime: clearCheckOutAvailabilityTime
? null
: (checkOutAvailabilityTime ?? this.checkOutAvailabilityTime),
);
}
@override
List<Object?> get props => <Object?>[
status,
todayShifts,
selectedShift,
attendance,
selectedDate,
checkInMode,
errorMessage,
isCheckInAllowed,
isCheckOutAllowed,
checkInAvailabilityTime,
checkOutAvailabilityTime,
];
}

View File

@@ -1,249 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:geolocator/geolocator.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/usecases/get_todays_shift_usecase.dart';
import '../../domain/usecases/get_attendance_status_usecase.dart';
import '../../domain/usecases/clock_in_usecase.dart';
import '../../domain/usecases/clock_out_usecase.dart';
import '../../domain/arguments/clock_in_arguments.dart';
import '../../domain/arguments/clock_out_arguments.dart';
import 'clock_in_event.dart';
import 'clock_in_state.dart';
class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
with BlocErrorHandler<ClockInState> {
ClockInBloc({
required GetTodaysShiftUseCase getTodaysShift,
required GetAttendanceStatusUseCase getAttendanceStatus,
required ClockInUseCase clockIn,
required ClockOutUseCase clockOut,
}) : _getTodaysShift = getTodaysShift,
_getAttendanceStatus = getAttendanceStatus,
_clockIn = clockIn,
_clockOut = clockOut,
super(ClockInState(selectedDate: DateTime.now())) {
on<ClockInPageLoaded>(_onLoaded);
on<ShiftSelected>(_onShiftSelected);
on<DateSelected>(_onDateSelected);
on<CheckInRequested>(_onCheckIn);
on<CheckOutRequested>(_onCheckOut);
on<CheckInModeChanged>(_onModeChanged);
on<RequestLocationPermission>(_onRequestLocationPermission);
on<CommuteModeToggled>(_onCommuteModeToggled);
on<LocationUpdated>(_onLocationUpdated);
add(ClockInPageLoaded());
}
final GetTodaysShiftUseCase _getTodaysShift;
final GetAttendanceStatusUseCase _getAttendanceStatus;
final ClockInUseCase _clockIn;
final ClockOutUseCase _clockOut;
// Mock Venue Location (e.g., Grand Hotel, NYC)
static const double allowedRadiusMeters = 500;
Future<void> _onLoaded(
ClockInPageLoaded event,
Emitter<ClockInState> emit,
) async {
emit(state.copyWith(status: ClockInStatus.loading));
await handleError(
emit: emit.call,
action: () async {
final List<Shift> shifts = await _getTodaysShift();
final AttendanceStatus status = await _getAttendanceStatus();
Shift? selectedShift;
if (shifts.isNotEmpty) {
if (status.activeShiftId != null) {
try {
selectedShift =
shifts.firstWhere((Shift s) => s.id == status.activeShiftId);
} catch (_) {}
}
selectedShift ??= shifts.last;
}
emit(state.copyWith(
status: ClockInStatus.success,
todayShifts: shifts,
selectedShift: selectedShift,
attendance: status,
));
if (selectedShift != null && !status.isCheckedIn) {
add(RequestLocationPermission());
}
},
onError: (String errorKey) => state.copyWith(
status: ClockInStatus.failure,
errorMessage: errorKey,
),
);
}
Future<void> _onRequestLocationPermission(
RequestLocationPermission event,
Emitter<ClockInState> emit,
) async {
await handleError(
emit: emit.call,
action: () async {
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
final bool hasConsent =
permission == LocationPermission.always ||
permission == LocationPermission.whileInUse;
emit(state.copyWith(hasLocationConsent: hasConsent));
if (hasConsent) {
await _startLocationUpdates();
}
},
onError: (String errorKey) => state.copyWith(
errorMessage: errorKey,
),
);
}
Future<void> _startLocationUpdates() async {
// Note: handleErrorWithResult could be used here too if we want centralized logging/conversion
try {
final Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
);
double distance = 0;
bool isVerified =
false; // Require location match by default if shift has location
if (state.selectedShift != null &&
state.selectedShift!.latitude != null &&
state.selectedShift!.longitude != null) {
distance = Geolocator.distanceBetween(
position.latitude,
position.longitude,
state.selectedShift!.latitude!,
state.selectedShift!.longitude!,
);
isVerified = distance <= allowedRadiusMeters;
} else {
isVerified = true;
}
if (!isClosed) {
add(
LocationUpdated(
position: position,
distance: distance,
isVerified: isVerified,
),
);
}
} catch (_) {
// Geolocator errors usually handled via onRequestLocationPermission
}
}
void _onLocationUpdated(
LocationUpdated event,
Emitter<ClockInState> emit,
) {
emit(state.copyWith(
currentLocation: event.position,
distanceFromVenue: event.distance,
isLocationVerified: event.isVerified,
etaMinutes:
(event.distance / 80).round(), // Rough estimate: 80m/min walking speed
));
}
void _onCommuteModeToggled(
CommuteModeToggled event,
Emitter<ClockInState> emit,
) {
emit(state.copyWith(isCommuteModeOn: event.isEnabled));
if (event.isEnabled) {
add(RequestLocationPermission());
}
}
void _onShiftSelected(
ShiftSelected event,
Emitter<ClockInState> emit,
) {
emit(state.copyWith(selectedShift: event.shift));
if (!state.attendance.isCheckedIn) {
_startLocationUpdates();
}
}
void _onDateSelected(
DateSelected event,
Emitter<ClockInState> emit,
) {
emit(state.copyWith(selectedDate: event.date));
}
void _onModeChanged(
CheckInModeChanged event,
Emitter<ClockInState> emit,
) {
emit(state.copyWith(checkInMode: event.mode));
}
Future<void> _onCheckIn(
CheckInRequested event,
Emitter<ClockInState> emit,
) async {
emit(state.copyWith(status: ClockInStatus.actionInProgress));
await handleError(
emit: emit.call,
action: () async {
final AttendanceStatus newStatus = await _clockIn(
ClockInArguments(shiftId: event.shiftId, notes: event.notes),
);
emit(state.copyWith(
status: ClockInStatus.success,
attendance: newStatus,
));
},
onError: (String errorKey) => state.copyWith(
status: ClockInStatus.failure,
errorMessage: errorKey,
),
);
}
Future<void> _onCheckOut(
CheckOutRequested event,
Emitter<ClockInState> emit,
) async {
emit(state.copyWith(status: ClockInStatus.actionInProgress));
await handleError(
emit: emit.call,
action: () async {
final AttendanceStatus newStatus = await _clockOut(
ClockOutArguments(
notes: event.notes,
breakTimeMinutes: 0,
applicationId: state.attendance.activeApplicationId,
),
);
emit(state.copyWith(
status: ClockInStatus.success,
attendance: newStatus,
));
},
onError: (String errorKey) => state.copyWith(
status: ClockInStatus.failure,
errorMessage: errorKey,
),
);
}
}

View File

@@ -1,80 +0,0 @@
import 'package:equatable/equatable.dart';
import 'package:geolocator/geolocator.dart';
import 'package:krow_domain/krow_domain.dart';
abstract class ClockInEvent extends Equatable {
const ClockInEvent();
@override
List<Object?> get props => <Object?>[];
}
class ClockInPageLoaded extends ClockInEvent {}
class ShiftSelected extends ClockInEvent {
const ShiftSelected(this.shift);
final Shift shift;
@override
List<Object?> get props => <Object?>[shift];
}
class DateSelected extends ClockInEvent {
const DateSelected(this.date);
final DateTime date;
@override
List<Object?> get props => <Object?>[date];
}
class CheckInRequested extends ClockInEvent {
const CheckInRequested({required this.shiftId, this.notes});
final String shiftId;
final String? notes;
@override
List<Object?> get props => <Object?>[shiftId, notes];
}
class CheckOutRequested extends ClockInEvent {
const CheckOutRequested({this.notes, this.breakTimeMinutes});
final String? notes;
final int? breakTimeMinutes;
@override
List<Object?> get props => <Object?>[notes, breakTimeMinutes];
}
class CheckInModeChanged extends ClockInEvent {
const CheckInModeChanged(this.mode);
final String mode;
@override
List<Object?> get props => <Object?>[mode];
}
class CommuteModeToggled extends ClockInEvent {
const CommuteModeToggled(this.isEnabled);
final bool isEnabled;
@override
List<Object?> get props => <Object?>[isEnabled];
}
class RequestLocationPermission extends ClockInEvent {}
class LocationUpdated extends ClockInEvent {
const LocationUpdated({required this.position, required this.distance, required this.isVerified});
final Position position;
final double distance;
final bool isVerified;
@override
List<Object?> get props => <Object?>[position, distance, isVerified];
}

View File

@@ -1,88 +0,0 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:geolocator/geolocator.dart';
enum ClockInStatus { initial, loading, success, failure, actionInProgress }
class ClockInState extends Equatable {
const ClockInState({
this.status = ClockInStatus.initial,
this.todayShifts = const <Shift>[],
this.selectedShift,
this.attendance = const AttendanceStatus(),
required this.selectedDate,
this.checkInMode = 'swipe',
this.errorMessage,
this.currentLocation,
this.distanceFromVenue,
this.isLocationVerified = false,
this.isCommuteModeOn = false,
this.hasLocationConsent = false,
this.etaMinutes,
});
final ClockInStatus status;
final List<Shift> todayShifts;
final Shift? selectedShift;
final AttendanceStatus attendance;
final DateTime selectedDate;
final String checkInMode;
final String? errorMessage;
final Position? currentLocation;
final double? distanceFromVenue;
final bool isLocationVerified;
final bool isCommuteModeOn;
final bool hasLocationConsent;
final int? etaMinutes;
ClockInState copyWith({
ClockInStatus? status,
List<Shift>? todayShifts,
Shift? selectedShift,
AttendanceStatus? attendance,
DateTime? selectedDate,
String? checkInMode,
String? errorMessage,
Position? currentLocation,
double? distanceFromVenue,
bool? isLocationVerified,
bool? isCommuteModeOn,
bool? hasLocationConsent,
int? etaMinutes,
}) {
return ClockInState(
status: status ?? this.status,
todayShifts: todayShifts ?? this.todayShifts,
selectedShift: selectedShift ?? this.selectedShift,
attendance: attendance ?? this.attendance,
selectedDate: selectedDate ?? this.selectedDate,
checkInMode: checkInMode ?? this.checkInMode,
errorMessage: errorMessage,
currentLocation: currentLocation ?? this.currentLocation,
distanceFromVenue: distanceFromVenue ?? this.distanceFromVenue,
isLocationVerified: isLocationVerified ?? this.isLocationVerified,
isCommuteModeOn: isCommuteModeOn ?? this.isCommuteModeOn,
hasLocationConsent: hasLocationConsent ?? this.hasLocationConsent,
etaMinutes: etaMinutes ?? this.etaMinutes,
);
}
@override
List<Object?> get props => <Object?>[
status,
todayShifts,
selectedShift,
attendance,
selectedDate,
checkInMode,
errorMessage,
currentLocation,
distanceFromVenue,
isLocationVerified,
isCommuteModeOn,
hasLocationConsent,
etaMinutes,
];
}

View File

@@ -0,0 +1,314 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../../data/services/background_geofence_service.dart';
import '../../../data/services/clock_in_notification_service.dart';
import '../../../domain/models/geofence_result.dart';
import '../../../domain/services/geofence_service_interface.dart';
import 'geofence_event.dart';
import 'geofence_state.dart';
/// BLoC that manages geofence verification and background tracking.
///
/// Handles foreground location stream monitoring, GPS timeout fallback,
/// and background periodic checks while clocked in.
class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
with
BlocErrorHandler<GeofenceState>,
SafeBloc<GeofenceEvent, GeofenceState> {
/// Creates a [GeofenceBloc] instance.
GeofenceBloc({
required GeofenceServiceInterface geofenceService,
required BackgroundGeofenceService backgroundGeofenceService,
required ClockInNotificationService notificationService,
}) : _geofenceService = geofenceService,
_backgroundGeofenceService = backgroundGeofenceService,
_notificationService = notificationService,
super(const GeofenceState.initial()) {
on<GeofenceStarted>(_onStarted);
on<GeofenceResultUpdated>(_onResultUpdated);
on<GeofenceTimeoutReached>(_onTimeout);
on<GeofenceServiceStatusChanged>(_onServiceStatusChanged);
on<GeofenceRetryRequested>(_onRetry);
on<BackgroundTrackingStarted>(_onBackgroundTrackingStarted);
on<BackgroundTrackingStopped>(_onBackgroundTrackingStopped);
on<GeofenceOverrideApproved>(_onOverrideApproved);
on<GeofenceStopped>(_onStopped);
}
/// Generation counter to discard stale geofence results when a new
/// [GeofenceStarted] event arrives before the previous check completes.
int _generation = 0;
/// The geofence service for foreground proximity checks.
final GeofenceServiceInterface _geofenceService;
/// The background service for periodic tracking while clocked in.
final BackgroundGeofenceService _backgroundGeofenceService;
/// The notification service for clock-in related notifications.
final ClockInNotificationService _notificationService;
/// Active subscription to the foreground geofence location stream.
StreamSubscription<GeofenceResult>? _geofenceSubscription;
/// Active subscription to the location service status stream.
StreamSubscription<bool>? _serviceStatusSubscription;
/// Handles the [GeofenceStarted] event by requesting permission, performing
/// an initial geofence check, and starting the foreground location stream.
Future<void> _onStarted(
GeofenceStarted event,
Emitter<GeofenceState> emit,
) async {
// Increment generation so in-flight results from previous shifts are
// discarded when they complete after a new GeofenceStarted fires.
_generation++;
final int currentGeneration = _generation;
// Reset override state from any previous shift and clear stale location
// data so the new shift starts with a clean geofence verification.
emit(state.copyWith(
isVerifying: true,
targetLat: event.targetLat,
targetLng: event.targetLng,
isGeofenceOverridden: false,
clearOverrideNotes: true,
isLocationVerified: false,
isLocationTimedOut: false,
clearCurrentLocation: true,
clearDistanceFromTarget: true,
));
await handleError(
emit: emit.call,
action: () async {
// Check permission first.
final LocationPermissionStatus permission = await _geofenceService.ensurePermission();
// Discard if a newer GeofenceStarted has fired while awaiting.
if (_generation != currentGeneration) return;
emit(state.copyWith(permissionStatus: permission));
if (permission == LocationPermissionStatus.denied ||
permission == LocationPermissionStatus.deniedForever ||
permission == LocationPermissionStatus.serviceDisabled) {
emit(state.copyWith(
isVerifying: false,
isLocationServiceEnabled:
permission != LocationPermissionStatus.serviceDisabled,
));
return;
}
// Start monitoring location service status changes.
await _serviceStatusSubscription?.cancel();
_serviceStatusSubscription =
_geofenceService.watchServiceStatus().listen((bool isEnabled) {
add(GeofenceServiceStatusChanged(isEnabled));
});
// Get initial position with a 30s timeout.
final GeofenceResult? result = await _geofenceService.checkGeofenceWithTimeout(
targetLat: event.targetLat,
targetLng: event.targetLng,
);
// Discard if a newer GeofenceStarted has fired while awaiting.
if (_generation != currentGeneration) return;
if (result == null) {
add(const GeofenceTimeoutReached());
} else {
add(GeofenceResultUpdated(result));
}
// Start continuous foreground location stream.
await _geofenceSubscription?.cancel();
_geofenceSubscription = _geofenceService
.watchGeofence(
targetLat: event.targetLat,
targetLng: event.targetLng,
)
.listen(
(GeofenceResult result) => add(GeofenceResultUpdated(result)),
);
},
onError: (String errorKey) => state.copyWith(
isVerifying: false,
),
);
}
/// Handles the [GeofenceResultUpdated] event by updating the state with
/// the latest location and distance data.
void _onResultUpdated(
GeofenceResultUpdated event,
Emitter<GeofenceState> emit,
) {
emit(state.copyWith(
isVerifying: false,
isLocationTimedOut: false,
currentLocation: event.result.location,
distanceFromTarget: event.result.distanceMeters,
isLocationVerified: event.result.isWithinRadius,
isLocationServiceEnabled: true,
));
}
/// Handles the [GeofenceTimeoutReached] event by marking the state as
/// timed out.
void _onTimeout(
GeofenceTimeoutReached event,
Emitter<GeofenceState> emit,
) {
emit(state.copyWith(
isVerifying: false,
isLocationTimedOut: true,
));
}
/// Handles the [GeofenceServiceStatusChanged] event. If services are
/// re-enabled after a timeout, automatically retries the check.
Future<void> _onServiceStatusChanged(
GeofenceServiceStatusChanged event,
Emitter<GeofenceState> emit,
) async {
emit(state.copyWith(isLocationServiceEnabled: event.isEnabled));
// If service re-enabled and we were timed out, retry automatically.
if (event.isEnabled && state.isLocationTimedOut) {
add(const GeofenceRetryRequested());
}
}
/// Handles the [GeofenceRetryRequested] event by re-checking the geofence
/// with the stored target coordinates.
Future<void> _onRetry(
GeofenceRetryRequested event,
Emitter<GeofenceState> emit,
) async {
if (state.targetLat == null || state.targetLng == null) return;
emit(state.copyWith(
isVerifying: true,
isLocationTimedOut: false,
));
await handleError(
emit: emit.call,
action: () async {
final GeofenceResult? result = await _geofenceService.checkGeofenceWithTimeout(
targetLat: state.targetLat!,
targetLng: state.targetLng!,
);
if (result == null) {
add(const GeofenceTimeoutReached());
} else {
add(GeofenceResultUpdated(result));
}
},
onError: (String errorKey) => state.copyWith(
isVerifying: false,
),
);
}
/// Handles the [BackgroundTrackingStarted] event by requesting "Always"
/// permission and starting periodic background checks.
Future<void> _onBackgroundTrackingStarted(
BackgroundTrackingStarted event,
Emitter<GeofenceState> emit,
) async {
await handleError(
emit: emit.call,
action: () async {
// Request upgrade to "Always" permission for background tracking.
final LocationPermissionStatus permission = await _geofenceService.requestAlwaysPermission();
emit(state.copyWith(permissionStatus: permission));
// Start background tracking regardless (degrades gracefully).
await _backgroundGeofenceService.startBackgroundTracking(
targetLat: event.targetLat,
targetLng: event.targetLng,
shiftId: event.shiftId,
leftGeofenceTitle: event.leftGeofenceTitle,
leftGeofenceBody: event.leftGeofenceBody,
);
// Show greeting notification using localized strings from the UI.
await _notificationService.showClockInGreeting(
title: event.greetingTitle,
body: event.greetingBody,
);
emit(state.copyWith(isBackgroundTrackingActive: true));
},
onError: (String errorKey) => state.copyWith(
isBackgroundTrackingActive: false,
),
);
}
/// Handles the [BackgroundTrackingStopped] event by stopping background
/// tracking.
Future<void> _onBackgroundTrackingStopped(
BackgroundTrackingStopped event,
Emitter<GeofenceState> emit,
) async {
await handleError(
emit: emit.call,
action: () async {
await _backgroundGeofenceService.stopBackgroundTracking();
// Show clock-out notification using localized strings from the UI.
await _notificationService.showClockOutNotification(
title: event.clockOutTitle,
body: event.clockOutBody,
);
emit(state.copyWith(isBackgroundTrackingActive: false));
},
onError: (String errorKey) => state.copyWith(
isBackgroundTrackingActive: false,
),
);
}
/// Handles the [GeofenceOverrideApproved] event by storing the override
/// flag and justification notes, enabling the swipe slider.
void _onOverrideApproved(
GeofenceOverrideApproved event,
Emitter<GeofenceState> emit,
) {
emit(state.copyWith(
isGeofenceOverridden: true,
overrideNotes: event.notes,
));
}
/// Handles the [GeofenceStopped] event by cancelling all subscriptions
/// and resetting the state.
Future<void> _onStopped(
GeofenceStopped event,
Emitter<GeofenceState> emit,
) async {
await _geofenceSubscription?.cancel();
_geofenceSubscription = null;
await _serviceStatusSubscription?.cancel();
_serviceStatusSubscription = null;
emit(const GeofenceState.initial());
}
@override
Future<void> close() {
_geofenceSubscription?.cancel();
_serviceStatusSubscription?.cancel();
return super.close();
}
}

View File

@@ -0,0 +1,147 @@
import 'package:equatable/equatable.dart';
import '../../../domain/models/geofence_result.dart';
/// Base event for the [GeofenceBloc].
abstract class GeofenceEvent extends Equatable {
/// Creates a [GeofenceEvent].
const GeofenceEvent();
@override
List<Object?> get props => <Object?>[];
}
/// Starts foreground geofence verification for a target location.
class GeofenceStarted extends GeofenceEvent {
/// Creates a [GeofenceStarted] event.
const GeofenceStarted({required this.targetLat, required this.targetLng});
/// Target latitude of the shift location.
final double targetLat;
/// Target longitude of the shift location.
final double targetLng;
@override
List<Object?> get props => <Object?>[targetLat, targetLng];
}
/// Emitted when a new geofence result is received from the location stream.
class GeofenceResultUpdated extends GeofenceEvent {
/// Creates a [GeofenceResultUpdated] event.
const GeofenceResultUpdated(this.result);
/// The latest geofence check result.
final GeofenceResult result;
@override
List<Object?> get props => <Object?>[result];
}
/// Emitted when the GPS timeout (30s) is reached without a location fix.
class GeofenceTimeoutReached extends GeofenceEvent {
/// Creates a [GeofenceTimeoutReached] event.
const GeofenceTimeoutReached();
}
/// Emitted when the device location service status changes.
class GeofenceServiceStatusChanged extends GeofenceEvent {
/// Creates a [GeofenceServiceStatusChanged] event.
const GeofenceServiceStatusChanged(this.isEnabled);
/// Whether location services are now enabled.
final bool isEnabled;
@override
List<Object?> get props => <Object?>[isEnabled];
}
/// User manually requests a geofence re-check.clock_in_body.dart
class GeofenceRetryRequested extends GeofenceEvent {
/// Creates a [GeofenceRetryRequested] event.
const GeofenceRetryRequested();
}
/// Starts background tracking after successful clock-in.
class BackgroundTrackingStarted extends GeofenceEvent {
/// Creates a [BackgroundTrackingStarted] event.
const BackgroundTrackingStarted({
required this.shiftId,
required this.targetLat,
required this.targetLng,
required this.greetingTitle,
required this.greetingBody,
required this.leftGeofenceTitle,
required this.leftGeofenceBody,
});
/// The shift ID being tracked.
final String shiftId;
/// Target latitude of the shift location.
final double targetLat;
/// Target longitude of the shift location.
final double targetLng;
/// Localized greeting notification title passed from the UI layer.
final String greetingTitle;
/// Localized greeting notification body passed from the UI layer.
final String greetingBody;
/// Localized title for the left-geofence notification, persisted to storage
/// for the background isolate.
final String leftGeofenceTitle;
/// Localized body for the left-geofence notification, persisted to storage
/// for the background isolate.
final String leftGeofenceBody;
@override
List<Object?> get props => <Object?>[
shiftId,
targetLat,
targetLng,
greetingTitle,
greetingBody,
leftGeofenceTitle,
leftGeofenceBody,
];
}
/// Stops background tracking after clock-out.
class BackgroundTrackingStopped extends GeofenceEvent {
/// Creates a [BackgroundTrackingStopped] event.
const BackgroundTrackingStopped({
required this.clockOutTitle,
required this.clockOutBody,
});
/// Localized clock-out notification title passed from the UI layer.
final String clockOutTitle;
/// Localized clock-out notification body passed from the UI layer.
final String clockOutBody;
@override
List<Object?> get props => <Object?>[clockOutTitle, clockOutBody];
}
/// Worker approved geofence override by providing justification notes.
class GeofenceOverrideApproved extends GeofenceEvent {
/// Creates a [GeofenceOverrideApproved] event.
const GeofenceOverrideApproved({required this.notes});
/// The justification notes provided by the worker.
final String notes;
@override
List<Object?> get props => <Object?>[notes];
}
/// Stops all geofence monitoring (foreground and background).
class GeofenceStopped extends GeofenceEvent {
/// Creates a [GeofenceStopped] event.
const GeofenceStopped();
}

View File

@@ -0,0 +1,125 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
/// State for the [GeofenceBloc].
class GeofenceState extends Equatable {
/// Creates a [GeofenceState] instance.
const GeofenceState({
this.permissionStatus,
this.isLocationServiceEnabled = true,
this.currentLocation,
this.distanceFromTarget,
this.isLocationVerified = false,
this.isLocationTimedOut = false,
this.isVerifying = false,
this.isBackgroundTrackingActive = false,
this.isGeofenceOverridden = false,
this.overrideNotes,
this.targetLat,
this.targetLng,
});
/// Initial state before any geofence operations.
const GeofenceState.initial() : this();
/// Current location permission status.
final LocationPermissionStatus? permissionStatus;
/// Whether device location services are enabled.
final bool isLocationServiceEnabled;
/// The device's current location, if available.
final DeviceLocation? currentLocation;
/// Distance from the target location in meters.
final double? distanceFromTarget;
/// Whether the device is within the 500m geofence radius.
final bool isLocationVerified;
/// Whether GPS timed out trying to get a fix.
final bool isLocationTimedOut;
/// Whether the BLoC is actively verifying location.
final bool isVerifying;
/// Whether background tracking is active.
final bool isBackgroundTrackingActive;
/// Whether the worker has overridden the geofence check via justification.
final bool isGeofenceOverridden;
/// Justification notes provided when overriding the geofence.
final String? overrideNotes;
/// Target latitude being monitored.
final double? targetLat;
/// Target longitude being monitored.
final double? targetLng;
/// Creates a copy with the given fields replaced.
///
/// Use the `clearX` flags to explicitly set nullable fields to `null`,
/// since the `??` fallback otherwise prevents clearing.
GeofenceState copyWith({
LocationPermissionStatus? permissionStatus,
bool clearPermissionStatus = false,
bool? isLocationServiceEnabled,
DeviceLocation? currentLocation,
bool clearCurrentLocation = false,
double? distanceFromTarget,
bool clearDistanceFromTarget = false,
bool? isLocationVerified,
bool? isLocationTimedOut,
bool? isVerifying,
bool? isBackgroundTrackingActive,
bool? isGeofenceOverridden,
String? overrideNotes,
bool clearOverrideNotes = false,
double? targetLat,
bool clearTargetLat = false,
double? targetLng,
bool clearTargetLng = false,
}) {
return GeofenceState(
permissionStatus: clearPermissionStatus
? null
: (permissionStatus ?? this.permissionStatus),
isLocationServiceEnabled:
isLocationServiceEnabled ?? this.isLocationServiceEnabled,
currentLocation: clearCurrentLocation
? null
: (currentLocation ?? this.currentLocation),
distanceFromTarget: clearDistanceFromTarget
? null
: (distanceFromTarget ?? this.distanceFromTarget),
isLocationVerified: isLocationVerified ?? this.isLocationVerified,
isLocationTimedOut: isLocationTimedOut ?? this.isLocationTimedOut,
isVerifying: isVerifying ?? this.isVerifying,
isBackgroundTrackingActive:
isBackgroundTrackingActive ?? this.isBackgroundTrackingActive,
isGeofenceOverridden: isGeofenceOverridden ?? this.isGeofenceOverridden,
overrideNotes:
clearOverrideNotes ? null : (overrideNotes ?? this.overrideNotes),
targetLat: clearTargetLat ? null : (targetLat ?? this.targetLat),
targetLng: clearTargetLng ? null : (targetLng ?? this.targetLng),
);
}
@override
List<Object?> get props => <Object?>[
permissionStatus,
isLocationServiceEnabled,
currentLocation,
distanceFromTarget,
isLocationVerified,
isLocationTimedOut,
isVerifying,
isBackgroundTrackingActive,
isGeofenceOverridden,
overrideNotes,
targetLat,
targetLng,
];
}

View File

@@ -1,155 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:geolocator/geolocator.dart';
// --- State ---
class ClockInState extends Equatable {
const ClockInState({
this.isLoading = false,
this.isLocationVerified = false,
this.error,
this.currentLocation,
this.distanceFromVenue,
this.isClockedIn = false,
this.clockInTime,
});
final bool isLoading;
final bool isLocationVerified;
final String? error;
final Position? currentLocation;
final double? distanceFromVenue;
final bool isClockedIn;
final DateTime? clockInTime;
ClockInState copyWith({
bool? isLoading,
bool? isLocationVerified,
String? error,
Position? currentLocation,
double? distanceFromVenue,
bool? isClockedIn,
DateTime? clockInTime,
}) {
return ClockInState(
isLoading: isLoading ?? this.isLoading,
isLocationVerified: isLocationVerified ?? this.isLocationVerified,
error: error, // Clear error if not provided
currentLocation: currentLocation ?? this.currentLocation,
distanceFromVenue: distanceFromVenue ?? this.distanceFromVenue,
isClockedIn: isClockedIn ?? this.isClockedIn,
clockInTime: clockInTime ?? this.clockInTime,
);
}
@override
List<Object?> get props => <Object?>[
isLoading,
isLocationVerified,
error,
currentLocation,
distanceFromVenue,
isClockedIn,
clockInTime,
];
}
// --- Cubit ---
class ClockInCubit extends Cubit<ClockInState> { // 500m radius
ClockInCubit() : super(const ClockInState());
// Mock Venue Location (e.g., Grand Hotel, NYC)
static const double venueLat = 40.7128;
static const double venueLng = -74.0060;
static const double allowedRadiusMeters = 500;
Future<void> checkLocationPermission() async {
emit(state.copyWith(isLoading: true, error: null));
try {
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
emit(state.copyWith(
isLoading: false,
error: 'Location permissions are denied',
));
return;
}
}
if (permission == LocationPermission.deniedForever) {
emit(state.copyWith(
isLoading: false,
error: 'Location permissions are permanently denied, we cannot request permissions.',
));
return;
}
await _getCurrentLocation();
} catch (e) {
emit(state.copyWith(isLoading: false, error: e.toString()));
}
}
Future<void> _getCurrentLocation() async {
try {
final Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
);
final double distance = Geolocator.distanceBetween(
position.latitude,
position.longitude,
venueLat,
venueLng,
);
final bool isWithinRadius = distance <= allowedRadiusMeters;
emit(state.copyWith(
isLoading: false,
currentLocation: position,
distanceFromVenue: distance,
isLocationVerified: isWithinRadius,
error: isWithinRadius ? null : 'You are ${distance.toStringAsFixed(0)}m away. You must be within ${allowedRadiusMeters}m.',
));
} catch (e) {
emit(state.copyWith(isLoading: false, error: 'Failed to get location: $e'));
}
}
Future<void> clockIn() async {
if (state.currentLocation == null) {
await checkLocationPermission();
if (state.currentLocation == null) return;
}
emit(state.copyWith(isLoading: true));
await Future.delayed(const Duration(seconds: 2));
emit(state.copyWith(
isLoading: false,
isClockedIn: true,
clockInTime: DateTime.now(),
));
}
Future<void> clockOut() async {
if (state.currentLocation == null) {
await checkLocationPermission();
if (state.currentLocation == null) return;
}
emit(state.copyWith(isLoading: true));
await Future.delayed(const Duration(seconds: 2));
emit(state.copyWith(
isLoading: false,
isClockedIn: false,
clockInTime: null,
));
}
}

View File

@@ -1,745 +1,75 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports, unused_element, unused_field, duplicate_ignore
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import '../bloc/clock_in_bloc.dart';
import '../bloc/clock_in_event.dart';
import '../bloc/clock_in_state.dart';
import '../bloc/clock_in/clock_in_bloc.dart';
import '../bloc/clock_in/clock_in_event.dart';
import '../bloc/clock_in/clock_in_state.dart';
import '../bloc/geofence/geofence_bloc.dart';
import '../widgets/clock_in_body.dart';
import '../widgets/clock_in_page_skeleton/clock_in_page_skeleton.dart';
import '../widgets/commute_tracker.dart';
import '../widgets/date_selector.dart';
import '../widgets/lunch_break_modal.dart';
import '../widgets/swipe_to_check_in.dart';
class ClockInPage extends StatefulWidget {
/// Top-level page for the staff clock-in feature.
///
/// Provides [ClockInBloc] and [GeofenceBloc], then delegates rendering to
/// [ClockInBody] (loaded) or [ClockInPageSkeleton] (loading). Error
/// snackbars are handled via [BlocListener].
class ClockInPage extends StatelessWidget {
/// Creates the clock-in page.
const ClockInPage({super.key});
@override
State<ClockInPage> createState() => _ClockInPageState();
}
class _ClockInPageState extends State<ClockInPage> {
late final ClockInBloc _bloc;
@override
void initState() {
super.initState();
_bloc = Modular.get<ClockInBloc>();
}
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
return BlocProvider<ClockInBloc>.value(
value: _bloc,
child: BlocConsumer<ClockInBloc, ClockInState>(
listener: (BuildContext context, ClockInState state) {
if (state.status == ClockInStatus.failure &&
state.errorMessage != null) {
return Scaffold(
appBar: UiAppBar(title: i18n.title, showBackButton: false),
body: MultiBlocProvider(
providers: <BlocProvider<dynamic>>[
BlocProvider<GeofenceBloc>.value(
value: Modular.get<GeofenceBloc>(),
),
BlocProvider<ClockInBloc>(
create: (BuildContext _) {
final ClockInBloc bloc = Modular.get<ClockInBloc>();
bloc.add(ClockInPageLoaded());
return bloc;
},
),
],
child: BlocListener<ClockInBloc, ClockInState>(
listenWhen: (ClockInState previous, ClockInState current) =>
current.status == ClockInStatus.failure &&
current.errorMessage != null &&
(previous.status != current.status ||
previous.errorMessage != current.errorMessage),
listener: (BuildContext context, ClockInState state) {
UiSnackbar.show(
context,
message: translateErrorKey(state.errorMessage!),
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, ClockInState state) {
if (state.status == ClockInStatus.loading &&
state.todayShifts.isEmpty) {
return Scaffold(
appBar: UiAppBar(title: i18n.title, showBackButton: false),
body: const SafeArea(child: ClockInPageSkeleton()),
);
}
},
child: BlocBuilder<ClockInBloc, ClockInState>(
buildWhen: (ClockInState previous, ClockInState current) =>
previous.status != current.status ||
previous.todayShifts != current.todayShifts,
builder: (BuildContext context, ClockInState state) {
final bool isInitialLoading =
state.status == ClockInStatus.loading &&
state.todayShifts.isEmpty;
final List<Shift> todayShifts = state.todayShifts;
final Shift? selectedShift = state.selectedShift;
final String? activeShiftId = state.attendance.activeShiftId;
final bool isActiveSelected =
selectedShift != null && selectedShift.id == activeShiftId;
final DateTime? checkInTime = isActiveSelected
? state.attendance.checkInTime
: null;
final DateTime? checkOutTime = isActiveSelected
? state.attendance.checkOutTime
: null;
final bool isCheckedIn =
state.attendance.isCheckedIn && isActiveSelected;
return Scaffold(
appBar: UiAppBar(title: i18n.title, showBackButton: false),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.only(
bottom: UiConstants.space24,
top: UiConstants.space6,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// // Commute Tracker (shows before date selector when applicable)
// if (selectedShift != null)
// CommuteTracker(
// shift: selectedShift,
// hasLocationConsent: state.hasLocationConsent,
// isCommuteModeOn: state.isCommuteModeOn,
// distanceMeters: state.distanceFromVenue,
// etaMinutes: state.etaMinutes,
// onCommuteToggled: (bool value) {
// _bloc.add(CommuteModeToggled(value));
// },
// ),
// Date Selector
DateSelector(
selectedDate: state.selectedDate,
onSelect: (DateTime date) =>
_bloc.add(DateSelected(date)),
shiftDates: <String>[
DateFormat('yyyy-MM-dd').format(DateTime.now()),
],
),
const SizedBox(height: UiConstants.space5),
// Your Activity Header
Text(
i18n.your_activity,
textAlign: TextAlign.start,
style: UiTypography.headline4m,
),
const SizedBox(height: UiConstants.space4),
// Selected Shift Info Card
if (todayShifts.isNotEmpty)
Column(
children: todayShifts
.map(
(Shift shift) => GestureDetector(
onTap: () =>
_bloc.add(ShiftSelected(shift)),
child: Container(
padding: const EdgeInsets.all(
UiConstants.space3,
),
margin: const EdgeInsets.only(
bottom: UiConstants.space3,
),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(
color: shift.id == selectedShift?.id
? UiColors.primary
: UiColors.border,
width: shift.id == selectedShift?.id
? 2
: 1,
),
),
child: Row(
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Text(
shift.id ==
selectedShift?.id
? i18n.selected_shift_badge
: i18n.today_shift_badge,
style: UiTypography
.titleUppercase4b
.copyWith(
color:
shift.id ==
selectedShift
?.id
? UiColors.primary
: UiColors
.textSecondary,
),
),
const SizedBox(height: 2),
Text(
shift.title,
style: UiTypography.body2b,
),
Text(
"${shift.clientName} ${shift.location}",
style: UiTypography
.body3r
.textSecondary,
),
],
),
),
Column(
crossAxisAlignment:
CrossAxisAlignment.end,
children: <Widget>[
Text(
"${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}",
style: UiTypography
.body3m
.textSecondary,
),
Text(
"\$${shift.hourlyRate}/hr",
style: UiTypography.body3m
.copyWith(
color: UiColors.primary,
),
),
],
),
],
),
),
),
)
.toList(),
),
// Swipe To Check In / Checked Out State / No Shift State
if (selectedShift != null &&
checkOutTime == null) ...<Widget>[
if (!isCheckedIn &&
!_isCheckInAllowed(selectedShift))
Container(
width: double.infinity,
padding: const EdgeInsets.all(
UiConstants.space6,
),
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: UiConstants.radiusLg,
),
child: Column(
children: <Widget>[
const Icon(
UiIcons.clock,
size: 48,
color: UiColors.iconThird,
),
const SizedBox(height: UiConstants.space4),
Text(
i18n.early_title,
style: UiTypography.body1m.textSecondary,
),
const SizedBox(height: UiConstants.space1),
Text(
i18n.check_in_at(
time: _getCheckInAvailabilityTime(
selectedShift,
),
),
style: UiTypography.body2r.textSecondary,
textAlign: TextAlign.center,
),
],
),
)
else ...<Widget>[
// Attire Photo Section
// if (!isCheckedIn) ...<Widget>[
// Container(
// padding: const EdgeInsets.all(
// UiConstants.space4,
// ),
// margin: const EdgeInsets.only(
// bottom: UiConstants.space4,
// ),
// decoration: BoxDecoration(
// color: UiColors.white,
// borderRadius: UiConstants.radiusLg,
// border: Border.all(color: UiColors.border),
// ),
// child: Row(
// children: <Widget>[
// Container(
// width: 48,
// height: 48,
// decoration: BoxDecoration(
// color: UiColors.bgSecondary,
// borderRadius: UiConstants.radiusMd,
// ),
// child: const Icon(
// UiIcons.camera,
// color: UiColors.primary,
// ),
// ),
// const SizedBox(width: UiConstants.space3),
// Expanded(
// child: Column(
// crossAxisAlignment:
// CrossAxisAlignment.start,
// children: <Widget>[
// Text(
// i18n.attire_photo_label,
// style: UiTypography.body2b,
// ),
// Text(
// i18n.attire_photo_desc,
// style: UiTypography
// .body3r
// .textSecondary,
// ),
// ],
// ),
// ),
// UiButton.secondary(
// text: i18n.take_attire_photo,
// onPressed: () {
// UiSnackbar.show(
// context,
// message: i18n.attire_captured,
// type: UiSnackbarType.success,
// );
// },
// ),
// ],
// ),
// ),
// ],
// if (!isCheckedIn &&
// (!state.isLocationVerified ||
// state.currentLocation ==
// null)) ...<Widget>[
// Container(
// width: double.infinity,
// padding: const EdgeInsets.all(
// UiConstants.space4,
// ),
// margin: const EdgeInsets.only(
// bottom: UiConstants.space4,
// ),
// decoration: BoxDecoration(
// color: UiColors.tagError,
// borderRadius: UiConstants.radiusLg,
// ),
// child: Row(
// children: [
// const Icon(
// UiIcons.error,
// color: UiColors.textError,
// size: 20,
// ),
// const SizedBox(width: UiConstants.space3),
// Expanded(
// child: Text(
// state.currentLocation == null
// ? i18n.location_verifying
// : i18n.not_in_range(
// distance: '500',
// ),
// style: UiTypography.body3m.textError,
// ),
// ),
// ],
// ),
// ),
// ],
SwipeToCheckIn(
isCheckedIn: isCheckedIn,
mode: state.checkInMode,
isDisabled: isCheckedIn,
isLoading:
state.status ==
ClockInStatus.actionInProgress,
onCheckIn: () async {
// Show NFC dialog if mode is 'nfc'
if (state.checkInMode == 'nfc') {
await _showNFCDialog(context);
} else {
_bloc.add(
CheckInRequested(
shiftId: selectedShift.id,
),
);
}
},
onCheckOut: () {
showDialog(
context: context,
builder: (BuildContext context) =>
LunchBreakDialog(
onComplete: () {
Navigator.of(
context,
).pop(); // Close dialog first
_bloc.add(
const CheckOutRequested(),
);
},
),
);
},
),
],
] else if (selectedShift != null &&
checkOutTime != null) ...<Widget>[
// Shift Completed State
Container(
padding: const EdgeInsets.all(UiConstants.space6),
decoration: BoxDecoration(
color: UiColors.tagSuccess,
borderRadius: UiConstants.radiusLg,
border: Border.all(
color: UiColors.success.withValues(
alpha: 0.3,
),
),
),
child: Column(
children: <Widget>[
Container(
width: 48,
height: 48,
decoration: const BoxDecoration(
color: UiColors.tagActive,
shape: BoxShape.circle,
),
child: const Icon(
UiIcons.check,
color: UiColors.textSuccess,
size: 24,
),
),
const SizedBox(height: UiConstants.space3),
Text(
i18n.shift_completed,
style: UiTypography.body1b.textSuccess,
),
const SizedBox(height: UiConstants.space1),
Text(
i18n.great_work,
style: UiTypography.body2r.textSuccess,
),
],
),
),
] else ...<Widget>[
// No Shift State
Container(
width: double.infinity,
padding: const EdgeInsets.all(UiConstants.space6),
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: UiConstants.radiusLg,
),
child: Column(
children: <Widget>[
Text(
i18n.no_shifts_today,
style: UiTypography.body1m.textSecondary,
textAlign: TextAlign.center,
),
const SizedBox(height: UiConstants.space1),
Text(
i18n.accept_shift_cta,
style: UiTypography.body2r.textSecondary,
textAlign: TextAlign.center,
),
],
),
),
],
// Checked In Banner
if (isCheckedIn && checkInTime != null) ...<Widget>[
const SizedBox(height: UiConstants.space3),
Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.tagSuccess,
borderRadius: UiConstants.radiusLg,
border: Border.all(
color: UiColors.success.withValues(
alpha: 0.3,
),
),
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: <Widget>[
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Text(
i18n.checked_in_at_label,
style: UiTypography.body3m.textSuccess,
),
Text(
DateFormat(
'h:mm a',
).format(checkInTime),
style: UiTypography.body1b.textSuccess,
),
],
),
Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
color: UiColors.tagActive,
shape: BoxShape.circle,
),
child: const Icon(
UiIcons.check,
color: UiColors.textSuccess,
),
),
],
),
),
],
const SizedBox(height: 16),
// Recent Activity List (Temporarily removed)
const SizedBox(height: 16),
],
),
),
],
),
),
),
);
},
),
);
}
Widget _buildModeTab(
String label,
IconData icon,
String value,
String currentMode,
) {
final bool isSelected = currentMode == value;
return Expanded(
child: GestureDetector(
onTap: () => _bloc.add(CheckInModeChanged(value)),
child: Container(
padding: const EdgeInsets.symmetric(vertical: UiConstants.space2),
decoration: BoxDecoration(
color: isSelected ? UiColors.white : UiColors.transparent,
borderRadius: UiConstants.radiusMd,
boxShadow: isSelected
? <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.05),
blurRadius: 2,
offset: const Offset(0, 1),
),
]
: <BoxShadow>[],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(
icon,
size: 16,
color: isSelected ? UiColors.foreground : UiColors.iconThird,
),
const SizedBox(width: 6),
Text(
label,
style: UiTypography.body2m.copyWith(
color: isSelected
? UiColors.foreground
: UiColors.textSecondary,
),
),
],
return isInitialLoading
? const ClockInPageSkeleton()
: const ClockInBody();
},
),
),
),
);
}
Future<void> _showNFCDialog(BuildContext context) async {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
bool scanned = false;
// Using a local navigator context since we are in a dialog
await showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return StatefulBuilder(
builder: (BuildContext context, setState) {
return AlertDialog(
title: Text(
scanned
? i18n.nfc_dialog.scanned_title
: i18n.nfc_dialog.scan_title,
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
width: 96,
height: 96,
decoration: BoxDecoration(
color: scanned
? UiColors.tagSuccess
: UiColors.tagInProgress,
shape: BoxShape.circle,
),
child: Icon(
scanned ? UiIcons.check : UiIcons.nfc,
size: 48,
color: scanned ? UiColors.textSuccess : UiColors.primary,
),
),
const SizedBox(height: UiConstants.space6),
Text(
scanned
? i18n.nfc_dialog.processing
: i18n.nfc_dialog.ready_to_scan,
style: UiTypography.headline4m,
),
const SizedBox(height: UiConstants.space2),
Text(
scanned
? i18n.nfc_dialog.please_wait
: i18n.nfc_dialog.scan_instruction,
textAlign: TextAlign.center,
style: UiTypography.body2r.textSecondary,
),
if (!scanned) ...<Widget>[
const SizedBox(height: UiConstants.space6),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: () async {
setState(() {
scanned = true;
});
// Simulate NFC scan delay
await Future.delayed(
const Duration(milliseconds: 1000),
);
if (!context.mounted) return;
Navigator.of(dialogContext).pop();
// Trigger BLoC event
// Need to access the bloc from the outer context or via passed reference
// Since _bloc is a field of the page state, we can use it if we are inside the page class
// But this dialog is just a function call.
// It's safer to just return a result
},
icon: const Icon(UiIcons.nfc, size: 24),
label: Text(
i18n.nfc_dialog.tap_to_scan,
style: UiTypography.headline4m.white,
),
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
foregroundColor: UiColors.white,
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusLg,
),
),
),
),
],
],
),
);
},
);
},
);
// After dialog closes, trigger the event if scan was successful (simulated)
// In real app, we would check the dialog result
if (scanned && _bloc.state.selectedShift != null) {
_bloc.add(CheckInRequested(shiftId: _bloc.state.selectedShift!.id));
}
}
// --- Helper Methods ---
String _formatTime(String timeStr) {
if (timeStr.isEmpty) return '';
try {
// Try parsing as ISO string first (which contains date)
final DateTime dt = DateTime.parse(timeStr);
return DateFormat('h:mm a').format(dt);
} catch (_) {
// Fallback for strict "HH:mm" or "HH:mm:ss" strings
try {
final List<String> 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 (e) {
return timeStr;
}
}
}
bool _isCheckInAllowed(Shift shift) {
try {
// Parse shift date (e.g. 2024-01-31T09:00:00)
// The Shift entity has 'date' which is the start DateTime string
final DateTime shiftStart = DateTime.parse(shift.startTime);
final DateTime windowStart = shiftStart.subtract(
const Duration(minutes: 15),
);
return DateTime.now().isAfter(windowStart);
} catch (e) {
// Fallback: If parsing fails, allow check in to avoid blocking.
return true;
}
}
String _getCheckInAvailabilityTime(Shift shift) {
try {
final DateTime shiftStart = DateTime.parse(shift.startTime.trim());
final DateTime windowStart = shiftStart.subtract(
const Duration(minutes: 15),
);
return DateFormat('h:mm a').format(windowStart);
} catch (e) {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
return i18n.soon;
}
}
}

View File

@@ -0,0 +1,23 @@
import 'package:flutter/widgets.dart';
/// Interface for different clock-in/out interaction methods (swipe, NFC, etc.).
///
/// Each implementation encapsulates the UI and behavior for a specific
/// check-in mode, allowing the action section to remain mode-agnostic.
abstract class CheckInInteraction {
/// Unique identifier for this interaction mode (e.g. "swipe", "nfc").
String get mode;
/// Builds the action widget for this interaction method.
///
/// The returned widget handles user interaction (swipe gesture, NFC tap,
/// etc.) and invokes [onCheckIn] or [onCheckOut] when the action completes.
Widget buildActionWidget({
required bool isCheckedIn,
required bool isDisabled,
required bool isLoading,
required bool hasClockinError,
required VoidCallback onCheckIn,
required VoidCallback onCheckOut,
});
}

View File

@@ -0,0 +1,119 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../widgets/nfc_scan_dialog.dart';
import 'check_in_interaction.dart';
/// NFC-based check-in interaction that shows a tap button and scan dialog.
///
/// When tapped, presents the [showNfcScanDialog] and triggers [onCheckIn]
/// or [onCheckOut] upon a successful scan.
class NfcCheckInInteraction implements CheckInInteraction {
/// Creates an NFC check-in interaction.
const NfcCheckInInteraction();
@override
String get mode => 'nfc';
@override
Widget buildActionWidget({
required bool isCheckedIn,
required bool isDisabled,
required bool isLoading,
required bool hasClockinError,
required VoidCallback onCheckIn,
required VoidCallback onCheckOut,
}) {
return _NfcCheckInButton(
isCheckedIn: isCheckedIn,
isDisabled: isDisabled,
isLoading: isLoading,
onCheckIn: onCheckIn,
onCheckOut: onCheckOut,
);
}
}
/// Tap button that launches the NFC scan dialog and triggers check-in/out.
class _NfcCheckInButton extends StatelessWidget {
const _NfcCheckInButton({
required this.isCheckedIn,
required this.isDisabled,
required this.isLoading,
required this.onCheckIn,
required this.onCheckOut,
});
/// Whether the user is currently checked in.
final bool isCheckedIn;
/// Whether the button should be disabled (e.g. geofence blocking).
final bool isDisabled;
/// Whether a check-in/out action is in progress.
final bool isLoading;
/// Called after a successful NFC scan when checking in.
final VoidCallback onCheckIn;
/// Called after a successful NFC scan when checking out.
final VoidCallback onCheckOut;
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInSwipeEn i18n =
Translations.of(context).staff.clock_in.swipe;
final Color baseColor = isCheckedIn ? UiColors.success : UiColors.primary;
return GestureDetector(
onTap: () => _handleTap(context),
child: Container(
height: 56,
decoration: BoxDecoration(
color: isDisabled ? UiColors.bgSecondary : baseColor,
borderRadius: UiConstants.radiusLg,
boxShadow: isDisabled
? <BoxShadow>[]
: <BoxShadow>[
BoxShadow(
color: baseColor.withValues(alpha: 0.4),
blurRadius: 25,
offset: const Offset(0, 10),
spreadRadius: -5,
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(UiIcons.wifi, color: UiColors.white),
const SizedBox(width: UiConstants.space3),
Text(
isLoading
? (isCheckedIn ? i18n.checking_out : i18n.checking_in)
: (isCheckedIn ? i18n.nfc_checkout : i18n.nfc_checkin),
style: UiTypography.body1b.copyWith(
color: isDisabled ? UiColors.textDisabled : UiColors.white,
),
),
],
),
),
);
}
/// Opens the NFC scan dialog and triggers the appropriate callback on success.
Future<void> _handleTap(BuildContext context) async {
if (isLoading || isDisabled) return;
final bool scanned = await showNfcScanDialog(context);
if (scanned && context.mounted) {
if (isCheckedIn) {
onCheckOut();
} else {
onCheckIn();
}
}
}
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter/widgets.dart';
import '../widgets/swipe_to_check_in.dart';
import 'check_in_interaction.dart';
/// Swipe-based check-in interaction using the [SwipeToCheckIn] slider widget.
class SwipeCheckInInteraction implements CheckInInteraction {
/// Creates a swipe check-in interaction.
const SwipeCheckInInteraction();
@override
String get mode => 'swipe';
@override
Widget buildActionWidget({
required bool isCheckedIn,
required bool isDisabled,
required bool isLoading,
required bool hasClockinError,
required VoidCallback onCheckIn,
required VoidCallback onCheckOut,
}) {
return SwipeToCheckIn(
isCheckedIn: isCheckedIn,
isDisabled: isDisabled,
isLoading: isLoading,
hasClockinError: hasClockinError,
onCheckIn: onCheckIn,
onCheckOut: onCheckOut,
);
}
}

View File

@@ -0,0 +1,79 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/clock_in/clock_in_bloc.dart';
import '../bloc/clock_in/clock_in_event.dart';
/// A single selectable tab within a check-in mode toggle strip.
///
/// Used to switch between different check-in methods (e.g. swipe, NFC).
class CheckInModeTab extends StatelessWidget {
/// Creates a mode tab.
const CheckInModeTab({
required this.label,
required this.icon,
required this.value,
required this.currentMode,
super.key,
});
/// The display label for this mode.
final String label;
/// The icon shown next to the label.
final IconData icon;
/// The mode value this tab represents.
final String value;
/// The currently active mode, used to determine selection state.
final String currentMode;
@override
Widget build(BuildContext context) {
final bool isSelected = currentMode == value;
return Expanded(
child: GestureDetector(
onTap: () =>
ReadContext(context).read<ClockInBloc>().add(CheckInModeChanged(value)),
child: Container(
padding: const EdgeInsets.symmetric(vertical: UiConstants.space2),
decoration: BoxDecoration(
color: isSelected ? UiColors.white : UiColors.transparent,
borderRadius: UiConstants.radiusMd,
boxShadow: isSelected
? <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.05),
blurRadius: 2,
offset: const Offset(0, 1),
),
]
: <BoxShadow>[],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(
icon,
size: 16,
color: isSelected ? UiColors.foreground : UiColors.iconThird,
),
const SizedBox(width: UiConstants.space1),
Text(
label,
style: UiTypography.body2m.copyWith(
color: isSelected
? UiColors.foreground
: UiColors.textSecondary,
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,63 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
/// A green-tinted banner confirming that the user is currently checked in.
///
/// Displays the exact check-in time alongside a check icon.
class CheckedInBanner extends StatelessWidget {
/// Creates a checked-in banner for the given [checkInTime].
const CheckedInBanner({required this.checkInTime, super.key});
/// The time the user checked in.
final DateTime checkInTime;
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
return Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.tagSuccess,
borderRadius: UiConstants.radiusLg,
border: Border.all(
color: UiColors.success.withValues(alpha: 0.3),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
i18n.checked_in_at_label,
style: UiTypography.body3m.textSuccess,
),
Text(
DateFormat('h:mm a').format(checkInTime),
style: UiTypography.body1b.textSuccess,
),
],
),
Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
color: UiColors.tagActive,
shape: BoxShape.circle,
),
child: const Icon(
UiIcons.check,
color: UiColors.textSuccess,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,211 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_clock_in/src/presentation/widgets/early_check_in_banner.dart';
import 'package:staff_clock_in/src/presentation/widgets/early_check_out_banner.dart';
import '../bloc/clock_in/clock_in_bloc.dart';
import '../bloc/clock_in/clock_in_event.dart';
import '../bloc/geofence/geofence_bloc.dart';
import '../bloc/geofence/geofence_state.dart';
import '../strategies/check_in_interaction.dart';
import '../strategies/nfc_check_in_interaction.dart';
import '../strategies/swipe_check_in_interaction.dart';
import 'geofence_status_banner/geofence_status_banner.dart';
import 'lunch_break_modal.dart';
import 'no_shifts_banner.dart';
import 'shift_completed_banner.dart';
/// Orchestrates which action widget is displayed based on the current state.
///
/// Uses the [CheckInInteraction] strategy pattern to delegate the actual
/// check-in/out UI to mode-specific implementations (swipe, NFC, etc.).
/// Also shows the [GeofenceStatusBanner]. Background tracking lifecycle
/// is managed by [ClockInBloc], not this widget.
class ClockInActionSection extends StatelessWidget {
/// Creates the action section.
const ClockInActionSection({
required this.selectedShift,
required this.isCheckedIn,
required this.checkOutTime,
required this.checkInMode,
required this.isActionInProgress,
this.hasClockinError = false,
this.isCheckInAllowed = true,
this.isCheckOutAllowed = true,
this.checkInAvailabilityTime,
this.checkOutAvailabilityTime,
super.key,
});
/// Available check-in interaction strategies keyed by mode identifier.
static const Map<String, CheckInInteraction> _interactions =
<String, CheckInInteraction>{
'swipe': SwipeCheckInInteraction(),
'nfc': NfcCheckInInteraction(),
};
/// The currently selected shift, or null if none is selected.
final Shift? selectedShift;
/// Whether the user is currently checked in for the active shift.
final bool isCheckedIn;
/// The check-out time, or null if the user has not checked out.
final DateTime? checkOutTime;
/// The current check-in mode (e.g. "swipe" or "nfc").
final String checkInMode;
/// Whether a check-in or check-out action is currently in progress.
final bool isActionInProgress;
/// Whether the last action attempt resulted in an error.
final bool hasClockinError;
/// Whether the time window allows check-in, computed by the BLoC.
final bool isCheckInAllowed;
/// Whether the time window allows check-out, computed by the BLoC.
final bool isCheckOutAllowed;
/// Formatted earliest time when check-in becomes available, or `null`.
final String? checkInAvailabilityTime;
/// Formatted earliest time when check-out becomes available, or `null`.
final String? checkOutAvailabilityTime;
/// Resolves the [CheckInInteraction] for the current mode.
///
/// Falls back to [SwipeCheckInInteraction] if the mode is unrecognized.
CheckInInteraction get _currentInteraction =>
_interactions[checkInMode] ?? const SwipeCheckInInteraction();
@override
Widget build(BuildContext context) {
if (selectedShift != null && checkOutTime == null) {
return _buildActiveShiftAction(context);
}
if (selectedShift != null && checkOutTime != null) {
return const ShiftCompletedBanner();
}
return const NoShiftsBanner();
}
/// Builds the action widget for an active (not completed) shift.
Widget _buildActiveShiftAction(BuildContext context) {
final String soonLabel = Translations.of(context).staff.clock_in.soon;
// Show geofence status and time-based availability banners when relevant.
if (!isCheckedIn && !isCheckInAllowed) {
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const GeofenceStatusBanner(),
const SizedBox(height: UiConstants.space3),
EarlyCheckInBanner(
availabilityTime: checkInAvailabilityTime ?? soonLabel,
),
],
);
}
if (isCheckedIn && !isCheckOutAllowed) {
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const GeofenceStatusBanner(),
const SizedBox(height: UiConstants.space3),
EarlyCheckOutBanner(
availabilityTime: checkOutAvailabilityTime ?? soonLabel,
),
],
);
}
return BlocBuilder<GeofenceBloc, GeofenceState>(
builder: (BuildContext context, GeofenceState geofenceState) {
final bool hasCoordinates =
selectedShift?.latitude != null && selectedShift?.longitude != null;
// Geofence only gates clock-in, never clock-out. When already
// checked in the swipe must always be enabled for checkout.
final bool isGeofenceBlocking =
hasCoordinates &&
!geofenceState.isLocationVerified &&
!geofenceState.isLocationTimedOut &&
!geofenceState.isGeofenceOverridden;
return Column(
mainAxisSize: MainAxisSize.min,
spacing: UiConstants.space4,
children: <Widget>[
const GeofenceStatusBanner(),
_currentInteraction.buildActionWidget(
isCheckedIn: isCheckedIn,
isDisabled: isGeofenceBlocking,
isLoading: isActionInProgress,
hasClockinError: hasClockinError,
onCheckIn: () => _handleCheckIn(context),
onCheckOut: () => _handleCheckOut(context),
),
],
);
},
);
}
/// Triggers the check-in flow, passing notification strings and
/// override notes from geofence state.
///
/// Returns early if [selectedShift] is null to avoid force-unwrap errors.
void _handleCheckIn(BuildContext context) {
if (selectedShift == null) return;
final GeofenceState geofenceState = ReadContext(
context,
).read<GeofenceBloc>().state;
final TranslationsStaffClockInGeofenceEn geofenceI18n = Translations.of(
context,
).staff.clock_in.geofence;
ReadContext(context).read<ClockInBloc>().add(
CheckInRequested(
shiftId: selectedShift!.id,
notes: geofenceState.overrideNotes,
clockInGreetingTitle: geofenceI18n.clock_in_greeting_title,
clockInGreetingBody: geofenceI18n.clock_in_greeting_body,
leftGeofenceTitle: geofenceI18n.background_left_title,
leftGeofenceBody: geofenceI18n.background_left_body,
),
);
}
/// Triggers the check-out flow via the lunch-break confirmation dialog.
void _handleCheckOut(BuildContext context) {
final TranslationsStaffClockInGeofenceEn geofenceI18n = Translations.of(
context,
).staff.clock_in.geofence;
showDialog<void>(
context: context,
builder: (BuildContext dialogContext) => LunchBreakDialog(
onComplete: (int breakTimeMinutes) {
Modular.to.popSafe();
ReadContext(context).read<ClockInBloc>().add(
CheckOutRequested(
breakTimeMinutes: breakTimeMinutes,
clockOutTitle: geofenceI18n.clock_out_title,
clockOutBody: geofenceI18n.clock_out_body,
),
);
},
),
);
}
}

View File

@@ -0,0 +1,152 @@
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:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import '../bloc/clock_in/clock_in_bloc.dart';
import '../bloc/clock_in/clock_in_event.dart';
import '../bloc/clock_in/clock_in_state.dart';
import '../bloc/geofence/geofence_bloc.dart';
import '../bloc/geofence/geofence_event.dart';
import 'checked_in_banner.dart';
import 'clock_in_action_section.dart';
import 'date_selector.dart';
import 'shift_card_list.dart';
/// The scrollable main content of the clock-in page.
///
/// Composes the date selector, activity header, shift cards, action section,
/// and the checked-in status banner into a single scrollable column.
/// Triggers geofence verification on mount and on shift selection changes.
class ClockInBody extends StatefulWidget {
/// Creates the clock-in body.
const ClockInBody({super.key});
@override
State<ClockInBody> createState() => _ClockInBodyState();
}
class _ClockInBodyState extends State<ClockInBody> {
@override
void initState() {
super.initState();
// Sync geofence on initial mount if a shift is already selected.
WidgetsBinding.instance.addPostFrameCallback((_) {
final Shift? selectedShift =
ReadContext(context).read<ClockInBloc>().state.selectedShift;
_syncGeofence(context, selectedShift);
});
}
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
return BlocListener<ClockInBloc, ClockInState>(
listenWhen: (ClockInState previous, ClockInState current) =>
previous.selectedShift != current.selectedShift,
listener: (BuildContext context, ClockInState state) {
_syncGeofence(context, state.selectedShift);
},
child: SingleChildScrollView(
padding: const EdgeInsets.only(
bottom: UiConstants.space24,
top: UiConstants.space6,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
child: BlocBuilder<ClockInBloc, ClockInState>(
builder: (BuildContext context, ClockInState state) {
final List<Shift> todayShifts = state.todayShifts;
final Shift? selectedShift = state.selectedShift;
final String? activeShiftId = state.attendance.activeShiftId;
final bool isActiveSelected =
selectedShift != null && selectedShift.id == activeShiftId;
final DateTime? checkInTime =
isActiveSelected ? state.attendance.checkInTime : null;
final DateTime? checkOutTime =
isActiveSelected ? state.attendance.checkOutTime : null;
final bool isCheckedIn =
state.attendance.isCheckedIn && isActiveSelected;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// date selector
DateSelector(
selectedDate: state.selectedDate,
onSelect: (DateTime date) =>
ReadContext(context).read<ClockInBloc>().add(DateSelected(date)),
shiftDates: <String>[
DateFormat('yyyy-MM-dd').format(DateTime.now()),
],
),
const SizedBox(height: UiConstants.space5),
Text(
i18n.your_activity,
textAlign: TextAlign.start,
style: UiTypography.headline4m,
),
const SizedBox(height: UiConstants.space4),
// today's shifts and actions
if (todayShifts.isNotEmpty)
ShiftCardList(
shifts: todayShifts,
selectedShiftId: selectedShift?.id,
onShiftSelected: (Shift shift) => ReadContext(context)
.read<ClockInBloc>()
.add(ShiftSelected(shift)),
),
// action section (check-in/out buttons)
ClockInActionSection(
selectedShift: selectedShift,
isCheckedIn: isCheckedIn,
checkOutTime: checkOutTime,
checkInMode: state.checkInMode,
isActionInProgress:
state.status == ClockInStatus.actionInProgress,
hasClockinError: state.status == ClockInStatus.failure,
isCheckInAllowed: state.isCheckInAllowed,
isCheckOutAllowed: state.isCheckOutAllowed,
checkInAvailabilityTime: state.checkInAvailabilityTime,
checkOutAvailabilityTime: state.checkOutAvailabilityTime,
),
// checked-in banner (only when checked in to the selected shift)
if (isCheckedIn && checkInTime != null) ...<Widget>[
const SizedBox(height: UiConstants.space3),
CheckedInBanner(checkInTime: checkInTime),
],
const SizedBox(height: UiConstants.space4),
],
);
},
),
),
),
);
}
/// Dispatches [GeofenceStarted] or [GeofenceStopped] based on whether
/// the selected shift has coordinates.
void _syncGeofence(BuildContext context, Shift? shift) {
final GeofenceBloc geofenceBloc = ReadContext(context).read<GeofenceBloc>();
if (shift != null && shift.latitude != null && shift.longitude != null) {
geofenceBloc.add(
GeofenceStarted(
targetLat: shift.latitude!,
targetLng: shift.longitude!,
),
);
} else {
geofenceBloc.add(const GeofenceStopped());
}
}
}

View File

@@ -16,7 +16,7 @@ class DateSelector extends StatelessWidget {
@override
Widget build(BuildContext context) {
final DateTime today = DateTime.now();
final List<DateTime> dates = List.generate(7, (int index) {
final List<DateTime> dates = List<DateTime>.generate(7, (int index) {
return today.add(Duration(days: index - 3));
});
@@ -31,7 +31,7 @@ class DateSelector extends StatelessWidget {
return Expanded(
child: GestureDetector(
onTap: () => onSelect(date),
onTap: isToday ? () => onSelect(date) : null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(
@@ -40,58 +40,55 @@ class DateSelector extends StatelessWidget {
decoration: BoxDecoration(
color: isSelected ? UiColors.primary : UiColors.white,
borderRadius: UiConstants.radiusLg,
boxShadow: isSelected
? <BoxShadow>[
BoxShadow(
color: UiColors.primary.withValues(alpha: 0.3),
blurRadius: 10,
offset: const Offset(0, 4),
),
]
: <BoxShadow>[],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
DateFormat('d').format(date),
style: UiTypography.title1m.copyWith(
fontWeight: FontWeight.bold,
color:
isSelected ? UiColors.white : UiColors.foreground,
),
),
const SizedBox(height: 2),
Text(
DateFormat('E').format(date),
style: UiTypography.footnote2r.copyWith(
color: isSelected
? UiColors.white.withValues(alpha: 0.8)
: UiColors.textInactive,
),
),
const SizedBox(height: UiConstants.space1),
if (hasShift)
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: isSelected ? UiColors.white : UiColors.primary,
shape: BoxShape.circle,
child: Opacity(
opacity: isToday ? 1.0 : 0.4,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
DateFormat('d').format(date),
style: UiTypography.title1m.copyWith(
fontWeight: FontWeight.bold,
color: isSelected
? UiColors.white
: UiColors.foreground,
),
)
else if (isToday && !isSelected)
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: UiColors.border,
shape: BoxShape.circle,
),
const SizedBox(height: 2),
Text(
DateFormat('E').format(date),
style: UiTypography.footnote2r.copyWith(
color: isSelected
? UiColors.white.withValues(alpha: 0.8)
: UiColors.textInactive,
),
)
else
const SizedBox(height: 6),
],
),
const SizedBox(height: UiConstants.space1),
if (hasShift)
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: isSelected
? UiColors.white
: UiColors.primary,
shape: BoxShape.circle,
),
)
else if (isToday && !isSelected)
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: UiColors.border,
shape: BoxShape.circle,
),
)
else
const SizedBox(height: UiConstants.space3),
],
),
),
),
),
@@ -100,11 +97,13 @@ class DateSelector extends StatelessWidget {
),
);
}
/// Helper to check if two dates are on the same calendar day (ignoring time).
bool _isSameDay(DateTime a, DateTime b) {
return a.year == b.year && a.month == b.month && a.day == b.day;
}
/// Formats a [DateTime] as an ISO date string (yyyy-MM-dd) for comparison with shift dates.
String _formatDateIso(DateTime date) {
return DateFormat('yyyy-MM-dd').format(date);
}

View File

@@ -0,0 +1,50 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Banner shown when the user arrives too early to check in.
///
/// Displays a clock icon and a message indicating when check-in
/// will become available.
class EarlyCheckInBanner extends StatelessWidget {
/// Creates an early check-in banner.
const EarlyCheckInBanner({
required this.availabilityTime,
super.key,
});
/// Formatted time string when check-in becomes available (e.g. "8:45 AM").
final String availabilityTime;
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(UiConstants.space6),
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: UiConstants.radiusLg,
),
child: Column(
children: <Widget>[
const Icon(UiIcons.clock, size: 48, color: UiColors.iconThird),
const SizedBox(height: UiConstants.space4),
Text(
i18n.early_title,
style: UiTypography.body1m.textSecondary,
),
const SizedBox(height: UiConstants.space1),
Text(
i18n.check_in_at(time: availabilityTime),
style: UiTypography.body2r.textSecondary,
textAlign: TextAlign.center,
),
],
),
);
}
}

View File

@@ -0,0 +1,50 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Banner shown when the user tries to check out too early.
///
/// Displays a clock icon and a message indicating when check-out
/// will become available.
class EarlyCheckOutBanner extends StatelessWidget {
/// Creates an early check-out banner.
const EarlyCheckOutBanner({
required this.availabilityTime,
super.key,
});
/// Formatted time string when check-out becomes available (e.g. "4:45 PM").
final String availabilityTime;
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(UiConstants.space6),
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: UiConstants.radiusLg,
),
child: Column(
children: <Widget>[
const Icon(UiIcons.clock, size: 48, color: UiColors.iconThird),
const SizedBox(height: UiConstants.space4),
Text(
i18n.early_checkout_title,
style: UiTypography.body1m.textSecondary,
),
const SizedBox(height: UiConstants.space1),
Text(
i18n.check_out_at(time: availabilityTime),
style: UiTypography.body2r.textSecondary,
textAlign: TextAlign.center,
),
],
),
);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Tappable text button used as a banner action.
class BannerActionButton extends StatelessWidget {
/// Creates a [BannerActionButton].
const BannerActionButton({
required this.label,
required this.onPressed,
this.color,
super.key,
});
/// Text label for the button.
final String label;
/// Callback when the button is pressed.
final VoidCallback onPressed;
/// Optional override color for the button text.
final Color? color;
@override
Widget build(BuildContext context) {
return UiButton.secondary(
text: label,
size: UiButtonSize.extraSmall,
style: color != null
? ButtonStyle(
foregroundColor: WidgetStateProperty.all(color),
side: WidgetStateProperty.all(BorderSide(color: color!)),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(borderRadius: UiConstants.radiusMd),
),
)
: ButtonStyle(
shape: WidgetStateProperty.all(
RoundedRectangleBorder(borderRadius: UiConstants.radiusMd),
),
),
onPressed: onPressed,
);
}
}

View File

@@ -0,0 +1,26 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A row that displays one or two banner action buttons with consistent spacing.
///
/// Used by geofence failure banners to show both the primary action
/// (e.g. "Retry", "Open Settings") and the "Clock In Anyway" override action.
class BannerActionsRow extends StatelessWidget {
/// Creates a [BannerActionsRow].
const BannerActionsRow({
required this.children,
super.key,
});
/// The action buttons to display in the row.
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: UiConstants.space2,
runSpacing: UiConstants.space2,
children: children,
);
}
}

View File

@@ -0,0 +1,121 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../../bloc/geofence/geofence_bloc.dart';
import '../../bloc/geofence/geofence_event.dart';
/// Modal bottom sheet that collects a justification note before allowing
/// a geofence-overridden clock-in.
///
/// The worker must provide a non-empty justification. On submit, a
/// [CheckInRequested] event is dispatched with [isGeofenceOverridden] set
/// to true and the justification as notes.
class GeofenceOverrideModal extends StatefulWidget {
/// Creates a [GeofenceOverrideModal].
const GeofenceOverrideModal({super.key});
/// Shows the override modal as a bottom sheet.
///
/// Requires [GeofenceBloc] to be available in [context].
static void show(BuildContext context) {
// Capture the bloc before opening the sheet so we don't access a
// deactivated widget's ancestor inside the builder.
final GeofenceBloc bloc = ReadContext(context).read<GeofenceBloc>();
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(UiConstants.space4),
),
),
builder: (_) => BlocProvider<GeofenceBloc>.value(
value: bloc,
child: const GeofenceOverrideModal(),
),
);
}
@override
State<GeofenceOverrideModal> createState() => _GeofenceOverrideModalState();
}
class _GeofenceOverrideModalState extends State<GeofenceOverrideModal> {
final TextEditingController _controller = TextEditingController();
/// Whether the submit button should be enabled.
bool _hasText = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInGeofenceEn i18n =
Translations.of(context).staff.clock_in.geofence;
return Padding(
padding: EdgeInsets.only(
left: UiConstants.space4,
right: UiConstants.space4,
top: UiConstants.space5,
bottom: MediaQuery.of(context).viewInsets.bottom + UiConstants.space4,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(i18n.override_title, style: UiTypography.title1b),
const SizedBox(height: UiConstants.space2),
Text(
i18n.override_desc,
style: UiTypography.body2r.textSecondary,
),
const SizedBox(height: UiConstants.space4),
UiTextField(
hintText: i18n.override_hint,
controller: _controller,
maxLines: 4,
autofocus: true,
textInputAction: TextInputAction.newline,
onChanged: (String value) {
final bool hasContent = value.trim().isNotEmpty;
if (hasContent != _hasText) {
setState(() => _hasText = hasContent);
}
},
),
const SizedBox(height: UiConstants.space4),
UiButton.primary(
text: i18n.override_submit,
fullWidth: true,
onPressed: _hasText ? () => _submit(context) : null,
),
const SizedBox(height: UiConstants.space2),
],
),
);
}
/// Stores the override justification in GeofenceBloc state (enabling the
/// swipe slider), then closes the modal.
void _submit(BuildContext context) {
final String justification = _controller.text.trim();
if (justification.isEmpty) return;
ReadContext(context).read<GeofenceBloc>().add(
GeofenceOverrideApproved(notes: justification),
);
Navigator.of(context).pop();
//Modular.to.popSafe();
}
}

View File

@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../bloc/geofence/geofence_bloc.dart';
import '../../bloc/geofence/geofence_state.dart';
import 'permission_denied_banner.dart';
import 'permission_denied_forever_banner.dart';
import 'service_disabled_banner.dart';
import 'overridden_banner.dart';
import 'timeout_banner.dart';
import 'too_far_banner.dart';
import 'verified_banner.dart';
import 'verifying_banner.dart';
/// Banner that displays the current geofence verification status.
///
/// Reads [GeofenceBloc] state directly and renders the appropriate
/// banner variant based on permission, location, and verification conditions.
class GeofenceStatusBanner extends StatelessWidget {
/// Creates a [GeofenceStatusBanner].
const GeofenceStatusBanner({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<GeofenceBloc, GeofenceState>(
builder: (BuildContext context, GeofenceState state) {
if (state.targetLat == null) {
return const SizedBox.shrink();
}
return _buildBannerForState(state);
},
);
}
/// Determines which banner variant to display based on the current state.
Widget _buildBannerForState(GeofenceState state) {
// If the worker overrode the geofence check, show a warning banner
// indicating location was not verified but justification was recorded.
if (state.isGeofenceOverridden) {
return const OverriddenBanner();
}
// 1. Location services disabled.
if (state.permissionStatus == LocationPermissionStatus.serviceDisabled ||
(state.isLocationTimedOut && !state.isLocationServiceEnabled)) {
return const ServiceDisabledBanner();
}
// 2. Permission denied (can re-request).
if (state.permissionStatus == LocationPermissionStatus.denied) {
return PermissionDeniedBanner(state: state);
}
// 3. Permission permanently denied.
if (state.permissionStatus == LocationPermissionStatus.deniedForever) {
return const PermissionDeniedForeverBanner();
}
// 4. Actively verifying location.
if (state.isVerifying) {
return const VerifyingBanner();
}
// 5. Location verified successfully.
if (state.isLocationVerified) {
return const VerifiedBanner();
}
// 6. Timed out but location services are enabled.
if (state.isLocationTimedOut && state.isLocationServiceEnabled) {
return const TimeoutBanner();
}
// 7. Not verified and too far away (distance known).
if (!state.isLocationVerified &&
!state.isLocationTimedOut &&
state.distanceFromTarget != null) {
return TooFarBanner(distanceMeters: state.distanceFromTarget!);
}
// Default: hide banner for unmatched states.
return const SizedBox.shrink();
}
}

View File

@@ -0,0 +1,27 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Banner shown when the worker has overridden the geofence check with a
/// justification note. Displays a warning indicating location was not verified.
class OverriddenBanner extends StatelessWidget {
/// Creates an [OverriddenBanner].
const OverriddenBanner({super.key});
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
context,
).staff.clock_in.geofence;
return UiNoticeBanner(
backgroundColor: UiColors.tagPending,
icon: UiIcons.warning,
iconColor: UiColors.textWarning,
title: i18n.overridden_title,
titleColor: UiColors.textWarning,
description: i18n.overridden_desc,
descriptionColor: UiColors.textWarning,
);
}
}

View File

@@ -0,0 +1,58 @@
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 '../../bloc/geofence/geofence_bloc.dart';
import '../../bloc/geofence/geofence_event.dart';
import '../../bloc/geofence/geofence_state.dart';
import 'banner_action_button.dart';
import 'banner_actions_row.dart';
import 'geofence_override_modal.dart';
/// Banner shown when location permission has been denied (can re-request).
class PermissionDeniedBanner extends StatelessWidget {
/// Creates a [PermissionDeniedBanner].
const PermissionDeniedBanner({required this.state, super.key});
/// Current geofence state used to re-dispatch [GeofenceStarted].
final GeofenceState state;
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
context,
).staff.clock_in.geofence;
return UiNoticeBanner(
backgroundColor: UiColors.tagError,
icon: UiIcons.error,
iconColor: UiColors.textError,
title: i18n.permission_required,
titleColor: UiColors.textError,
description: i18n.permission_required_desc,
descriptionColor: UiColors.textError,
action: BannerActionsRow(
children: <Widget>[
BannerActionButton(
label: i18n.grant_permission,
onPressed: () {
if (state.targetLat != null && state.targetLng != null) {
ReadContext(context).read<GeofenceBloc>().add(
GeofenceStarted(
targetLat: state.targetLat!,
targetLng: state.targetLng!,
),
);
}
},
),
BannerActionButton(
label: i18n.clock_in_anyway,
onPressed: () => GeofenceOverrideModal.show(context),
),
],
),
);
}
}

View File

@@ -0,0 +1,46 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import '../../../domain/services/geofence_service_interface.dart';
import 'banner_action_button.dart';
import 'banner_actions_row.dart';
import 'geofence_override_modal.dart';
/// Banner shown when location permission has been permanently denied.
class PermissionDeniedForeverBanner extends StatelessWidget {
/// Creates a [PermissionDeniedForeverBanner].
const PermissionDeniedForeverBanner({super.key});
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
context,
).staff.clock_in.geofence;
return UiNoticeBanner(
backgroundColor: UiColors.tagError,
icon: UiIcons.error,
iconColor: UiColors.textError,
title: i18n.permission_denied_forever,
titleColor: UiColors.textError,
description: i18n.permission_denied_forever_desc,
descriptionColor: UiColors.textError,
action: BannerActionsRow(
children: <Widget>[
BannerActionButton(
label: i18n.clock_in_anyway,
color: UiColors.textError,
onPressed: () => GeofenceOverrideModal.show(context),
),
BannerActionButton(
label: i18n.open_settings,
onPressed: () =>
Modular.get<GeofenceServiceInterface>().openAppSettings(),
),
],
),
);
}
}

View File

@@ -0,0 +1,43 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import '../../../domain/services/geofence_service_interface.dart';
import 'banner_action_button.dart';
import 'banner_actions_row.dart';
import 'geofence_override_modal.dart';
/// Banner shown when device location services are disabled.
class ServiceDisabledBanner extends StatelessWidget {
/// Creates a [ServiceDisabledBanner].
const ServiceDisabledBanner({super.key});
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
context,
).staff.clock_in.geofence;
return UiNoticeBanner(
backgroundColor: UiColors.tagError,
icon: UiIcons.error,
iconColor: UiColors.textError,
title: i18n.service_disabled,
titleColor: UiColors.textError,
action: BannerActionsRow(
children: <Widget>[
BannerActionButton(
label: i18n.open_settings,
onPressed: () =>
Modular.get<GeofenceServiceInterface>().openLocationSettings(),
),
BannerActionButton(
label: i18n.clock_in_anyway,
onPressed: () => GeofenceOverrideModal.show(context),
),
],
),
);
}
}

View File

@@ -0,0 +1,51 @@
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 '../../bloc/geofence/geofence_bloc.dart';
import '../../bloc/geofence/geofence_event.dart';
import 'banner_action_button.dart';
import 'banner_actions_row.dart';
import 'geofence_override_modal.dart';
/// Banner shown when GPS timed out but location services are enabled.
class TimeoutBanner extends StatelessWidget {
/// Creates a [TimeoutBanner].
const TimeoutBanner({super.key});
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
context,
).staff.clock_in.geofence;
return UiNoticeBanner(
backgroundColor: UiColors.tagPending,
icon: UiIcons.warning,
iconColor: UiColors.textWarning,
title: i18n.timeout_title,
titleColor: UiColors.textWarning,
description: i18n.timeout_desc,
descriptionColor: UiColors.textWarning,
action: BannerActionsRow(
children: <Widget>[
BannerActionButton(
label: i18n.retry,
color: UiColors.textWarning,
onPressed: () {
ReadContext(context).read<GeofenceBloc>().add(
const GeofenceRetryRequested(),
);
},
),
BannerActionButton(
label: i18n.clock_in_anyway,
color: UiColors.textWarning,
onPressed: () => GeofenceOverrideModal.show(context),
),
],
),
);
}
}

View File

@@ -0,0 +1,38 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_core/core.dart';
import 'banner_action_button.dart';
import 'geofence_override_modal.dart';
/// Banner shown when the device is outside the geofence radius.
class TooFarBanner extends StatelessWidget {
/// Creates a [TooFarBanner].
const TooFarBanner({required this.distanceMeters, super.key});
/// Distance from the target location in meters.
final double distanceMeters;
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
context,
).staff.clock_in.geofence;
return UiNoticeBanner(
backgroundColor: UiColors.tagPending,
icon: UiIcons.warning,
iconColor: UiColors.textWarning,
title: i18n.too_far_title,
titleColor: UiColors.textWarning,
description: i18n.too_far_desc(distance: formatDistance(distanceMeters)),
descriptionColor: UiColors.textWarning,
action: BannerActionButton(
label: i18n.clock_in_anyway,
color: UiColors.textWarning,
onPressed: () => GeofenceOverrideModal.show(context),
),
);
}
}

View File

@@ -0,0 +1,24 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Banner shown when the device location has been verified within range.
class VerifiedBanner extends StatelessWidget {
/// Creates a [VerifiedBanner].
const VerifiedBanner({super.key});
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
context,
).staff.clock_in.geofence;
return UiNoticeBanner(
backgroundColor: UiColors.tagSuccess,
icon: UiIcons.checkCircle,
iconColor: UiColors.textSuccess,
title: i18n.verified,
titleColor: UiColors.textSuccess,
);
}
}

View File

@@ -0,0 +1,31 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Banner shown while actively verifying the device location.
class VerifyingBanner extends StatelessWidget {
/// Creates a [VerifyingBanner].
const VerifyingBanner({super.key});
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
context,
).staff.clock_in.geofence;
return UiNoticeBanner(
backgroundColor: UiColors.tagInProgress,
iconColor: UiColors.primary,
title: i18n.verifying,
titleColor: UiColors.primary,
leading: const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: UiColors.primary,
),
),
);
}
}

View File

@@ -3,9 +3,16 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Dialog that collects lunch break information during the check-out flow.
///
/// Returns the break duration in minutes via [onComplete]. If the user
/// indicates they did not take a lunch break, the value will be `0`.
class LunchBreakDialog extends StatefulWidget {
/// Creates a [LunchBreakDialog] with the required [onComplete] callback.
const LunchBreakDialog({super.key, required this.onComplete});
final VoidCallback onComplete;
/// Called when the user finishes the dialog, passing break time in minutes.
final ValueChanged<int> onComplete;
@override
State<LunchBreakDialog> createState() => _LunchBreakDialogState();
@@ -25,6 +32,36 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
final List<String> _timeOptions = _generateTimeOptions();
/// Computes the break duration in minutes from [_breakStart] and [_breakEnd].
///
/// Returns `0` when the user did not take lunch or the times are invalid.
int _computeBreakMinutes() {
if (_tookLunch != true || _breakStart == null || _breakEnd == null) {
return 0;
}
final int? startMinutes = _parseTimeToMinutes(_breakStart!);
final int? endMinutes = _parseTimeToMinutes(_breakEnd!);
if (startMinutes == null || endMinutes == null) return 0;
final int diff = endMinutes - startMinutes;
return diff > 0 ? diff : 0;
}
/// Parses a time string like "12:30pm" into total minutes since midnight.
static int? _parseTimeToMinutes(String time) {
final String lower = time.toLowerCase().trim();
final bool isPm = lower.endsWith('pm');
final String cleaned = lower.replaceAll(RegExp(r'[ap]m'), '');
final List<String> parts = cleaned.split(':');
if (parts.length != 2) return null;
final int? hour = int.tryParse(parts[0]);
final int? minute = int.tryParse(parts[1]);
if (hour == null || minute == null) return null;
int hour24 = hour;
if (isPm && hour != 12) hour24 += 12;
if (!isPm && hour == 12) hour24 = 0;
return hour24 * 60 + minute;
}
static List<String> _generateTimeOptions() {
final List<String> options = <String>[];
for (int h = 0; h < 24; h++) {
@@ -258,9 +295,11 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
const SizedBox(height: UiConstants.space6),
ElevatedButton(
onPressed: () {
setState(() => _step = 3);
},
onPressed: _noLunchReason != null
? () {
setState(() => _step = 3);
}
: null,
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
minimumSize: const Size(double.infinity, 48),
@@ -325,7 +364,7 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
),
const SizedBox(height: UiConstants.space6),
ElevatedButton(
onPressed: widget.onComplete,
onPressed: () => widget.onComplete(_computeBreakMinutes()),
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
minimumSize: const Size(double.infinity, 48),

View File

@@ -0,0 +1,129 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
/// Shows the NFC scanning dialog and returns `true` when a scan completes.
///
/// The dialog is non-dismissible and simulates an NFC tap with a short delay.
/// Returns `false` if the dialog is closed without a successful scan.
Future<bool> showNfcScanDialog(BuildContext context) async {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
bool scanned = false;
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return AlertDialog(
title: Text(
scanned
? i18n.nfc_dialog.scanned_title
: i18n.nfc_dialog.scan_title,
),
content: _NfcDialogContent(
scanned: scanned,
i18n: i18n,
onTapToScan: () async {
setState(() {
scanned = true;
});
await Future<void>.delayed(
const Duration(milliseconds: 1000),
);
if (!context.mounted) return;
Modular.to.popSafe();
},
),
);
},
);
},
);
return scanned;
}
/// Internal content widget for the NFC scan dialog.
///
/// Displays the scan icon/status and a tap-to-scan button.
class _NfcDialogContent extends StatelessWidget {
const _NfcDialogContent({
required this.scanned,
required this.i18n,
required this.onTapToScan,
});
/// Whether an NFC tag has been scanned.
final bool scanned;
/// Localization accessor for clock-in strings.
final TranslationsStaffClockInEn i18n;
/// Called when the user taps the scan button.
final VoidCallback onTapToScan;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
width: 96,
height: 96,
decoration: BoxDecoration(
color: scanned ? UiColors.tagSuccess : UiColors.tagInProgress,
shape: BoxShape.circle,
),
child: Icon(
scanned ? UiIcons.check : UiIcons.nfc,
size: 48,
color: scanned ? UiColors.textSuccess : UiColors.primary,
),
),
const SizedBox(height: UiConstants.space6),
Text(
scanned
? i18n.nfc_dialog.processing
: i18n.nfc_dialog.ready_to_scan,
style: UiTypography.headline4m,
),
const SizedBox(height: UiConstants.space2),
Text(
scanned
? i18n.nfc_dialog.please_wait
: i18n.nfc_dialog.scan_instruction,
textAlign: TextAlign.center,
style: UiTypography.body2r.textSecondary,
),
if (!scanned) ...<Widget>[
const SizedBox(height: UiConstants.space6),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: onTapToScan,
icon: const Icon(UiIcons.nfc, size: 24),
label: Text(
i18n.nfc_dialog.tap_to_scan,
style: UiTypography.headline4m.white,
),
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
foregroundColor: UiColors.white,
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusLg,
),
),
),
),
],
],
);
}
}

View File

@@ -0,0 +1,42 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Placeholder banner shown when there are no shifts scheduled for today.
///
/// Encourages the user to browse available shifts.
class NoShiftsBanner extends StatelessWidget {
/// Creates a no-shifts banner.
const NoShiftsBanner({super.key});
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(UiConstants.space6),
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: UiConstants.radiusLg,
),
child: Column(
children: <Widget>[
Text(
i18n.no_shifts_today,
style: UiTypography.body1m.textSecondary,
textAlign: TextAlign.center,
),
const SizedBox(height: UiConstants.space1),
Text(
i18n.accept_shift_cta,
style: UiTypography.body2r.textSecondary,
textAlign: TextAlign.center,
),
],
),
);
}
}

View File

@@ -0,0 +1,127 @@
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:krow_core/core.dart' show formatTime;
/// A selectable card that displays a single shift's summary information.
///
/// Shows the shift title, client/location, time range, and hourly rate.
/// Highlights with a primary border when [isSelected] is true.
class ShiftCard extends StatelessWidget {
/// Creates a shift card for the given [shift].
const ShiftCard({
required this.shift,
required this.isSelected,
required this.onTap,
super.key,
});
/// The shift to display.
final Shift shift;
/// Whether this card is currently selected.
final bool isSelected;
/// Called when the user taps this card.
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(UiConstants.space3),
margin: const EdgeInsets.only(bottom: UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(
color: isSelected ? UiColors.primary : UiColors.border,
width: isSelected ? 2 : 1,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(child: _ShiftDetails(shift: shift, isSelected: isSelected, i18n: i18n)),
_ShiftTimeAndRate(shift: shift),
],
),
),
);
}
}
/// Displays the shift title, client name, and location on the left side.
class _ShiftDetails extends StatelessWidget {
const _ShiftDetails({
required this.shift,
required this.isSelected,
required this.i18n,
});
/// The shift whose details to display.
final Shift shift;
/// Whether the parent card is selected.
final bool isSelected;
/// Localization accessor for clock-in strings.
final TranslationsStaffClockInEn i18n;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
isSelected ? i18n.selected_shift_badge : i18n.today_shift_badge,
style: UiTypography.titleUppercase4b.copyWith(
color: isSelected ? UiColors.primary : UiColors.textSecondary,
),
),
const SizedBox(height: 2),
Text(shift.title, style: UiTypography.body2b),
Text(
'${shift.clientName} ${shift.location}',
style: UiTypography.body3r.textSecondary,
),
],
);
}
}
/// Displays the shift time range and hourly rate on the right side.
class _ShiftTimeAndRate extends StatelessWidget {
const _ShiftTimeAndRate({required this.shift});
/// The shift whose time and rate to display.
final Shift shift;
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
'${formatTime(shift.startTime)} - ${formatTime(shift.endTime)}',
style: UiTypography.body3m.textSecondary,
),
Text(
i18n.per_hr(amount: shift.hourlyRate),
style: UiTypography.body3m.copyWith(color: UiColors.primary),
),
],
);
}
}

View File

@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
import 'shift_card.dart';
/// Renders a vertical list of [ShiftCard] widgets for today's shifts.
///
/// Highlights the currently selected shift and notifies the parent
/// when a different shift is tapped.
class ShiftCardList extends StatelessWidget {
/// Creates a shift card list from [shifts].
const ShiftCardList({
required this.shifts,
required this.selectedShiftId,
required this.onShiftSelected,
super.key,
});
/// All shifts to display.
final List<Shift> shifts;
/// The ID of the currently selected shift, if any.
final String? selectedShiftId;
/// Called when the user taps a shift card.
final ValueChanged<Shift> onShiftSelected;
@override
Widget build(BuildContext context) {
return Column(
children: shifts
.map(
(Shift shift) => ShiftCard(
shift: shift,
isSelected: shift.id == selectedShiftId,
onTap: () => onShiftSelected(shift),
),
)
.toList(),
);
}
}

View File

@@ -0,0 +1,50 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Success banner displayed after a shift has been completed.
///
/// Shows a check icon with congratulatory text in a green-tinted container.
class ShiftCompletedBanner extends StatelessWidget {
/// Creates a shift completed banner.
const ShiftCompletedBanner({super.key});
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
return Container(
padding: const EdgeInsets.all(UiConstants.space6),
decoration: BoxDecoration(
color: UiColors.tagSuccess,
borderRadius: UiConstants.radiusLg,
border: Border.all(
color: UiColors.success.withValues(alpha: 0.3),
),
),
child: Column(
children: <Widget>[
Container(
width: 48,
height: 48,
decoration: const BoxDecoration(
color: UiColors.tagActive,
shape: BoxShape.circle,
),
child: const Icon(
UiIcons.check,
color: UiColors.textSuccess,
size: 24,
),
),
const SizedBox(height: UiConstants.space3),
Text(i18n.shift_completed, style: UiTypography.body1b.textSuccess),
const SizedBox(height: UiConstants.space1),
Text(i18n.great_work, style: UiTypography.body2r.textSuccess),
],
),
);
}
}

View File

@@ -3,23 +3,41 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A swipe-to-confirm slider for clock-in and clock-out actions.
///
/// Displays a draggable handle that the user slides to the end to confirm
/// check-in or check-out. This widget only handles the swipe interaction;
/// NFC mode is handled by a separate [CheckInInteraction] strategy.
class SwipeToCheckIn extends StatefulWidget {
/// Creates a swipe-to-check-in slider.
const SwipeToCheckIn({
super.key,
this.onCheckIn,
this.onCheckOut,
this.isLoading = false,
this.mode = 'swipe',
this.isCheckedIn = false,
this.isDisabled = false,
this.hasClockinError = false,
});
/// Called when the user completes the swipe to check in.
final VoidCallback? onCheckIn;
/// Called when the user completes the swipe to check out.
final VoidCallback? onCheckOut;
/// Whether a check-in/out action is currently in progress.
final bool isLoading;
final String mode; // 'swipe' or 'nfc'
/// Whether the user is currently checked in.
final bool isCheckedIn;
/// Whether the slider is disabled (e.g. geofence blocking).
final bool isDisabled;
/// Whether an error occurred during the last action attempt.
final bool hasClockinError;
@override
State<SwipeToCheckIn> createState() => _SwipeToCheckInState();
}
@@ -33,12 +51,23 @@ class _SwipeToCheckInState extends State<SwipeToCheckIn>
@override
void didUpdateWidget(SwipeToCheckIn oldWidget) {
super.didUpdateWidget(oldWidget);
// Reset on check-in state change (successful action).
if (widget.isCheckedIn != oldWidget.isCheckedIn) {
setState(() {
_isComplete = false;
_dragValue = 0.0;
});
}
// Reset on error: loading finished without state change, or validation error.
if (_isComplete &&
widget.isCheckedIn == oldWidget.isCheckedIn &&
((oldWidget.isLoading && !widget.isLoading) ||
(!oldWidget.hasClockinError && widget.hasClockinError))) {
setState(() {
_isComplete = false;
_dragValue = 0.0;
});
}
}
void _onDragUpdate(DragUpdateDetails details, double maxWidth) {
@@ -60,6 +89,7 @@ class _SwipeToCheckInState extends State<SwipeToCheckIn>
_isComplete = true;
});
Future.delayed(const Duration(milliseconds: 300), () {
if (!mounted) return;
if (widget.isCheckedIn) {
widget.onCheckOut?.call();
} else {
@@ -76,57 +106,6 @@ class _SwipeToCheckInState extends State<SwipeToCheckIn>
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInSwipeEn i18n = Translations.of(context).staff.clock_in.swipe;
final Color baseColor = widget.isCheckedIn
? UiColors.success
: UiColors.primary;
if (widget.mode == 'nfc') {
return GestureDetector(
onTap: () {
if (widget.isLoading || widget.isDisabled) return;
// Simulate completion for NFC tap
Future.delayed(const Duration(milliseconds: 300), () {
if (widget.isCheckedIn) {
widget.onCheckOut?.call();
} else {
widget.onCheckIn?.call();
}
});
},
child: Container(
height: 56,
decoration: BoxDecoration(
color: widget.isDisabled ? UiColors.bgSecondary : baseColor,
borderRadius: UiConstants.radiusLg,
boxShadow: widget.isDisabled ? [] : <BoxShadow>[
BoxShadow(
color: baseColor.withValues(alpha: 0.4),
blurRadius: 25,
offset: const Offset(0, 10),
spreadRadius: -5,
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(UiIcons.wifi, color: UiColors.white),
const SizedBox(width: UiConstants.space3),
Text(
widget.isLoading
? (widget.isCheckedIn
? i18n.checking_out
: i18n.checking_in)
: (widget.isCheckedIn ? i18n.nfc_checkout : i18n.nfc_checkin),
style: UiTypography.body1b.copyWith(
color: widget.isDisabled ? UiColors.textDisabled : UiColors.white,
),
),
],
),
),
);
}
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
@@ -151,13 +130,6 @@ class _SwipeToCheckInState extends State<SwipeToCheckIn>
decoration: BoxDecoration(
color: currentColor,
borderRadius: UiConstants.radiusLg,
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Stack(
children: <Widget>[

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