diff --git a/.agent/skills/krow-mobile-architecture/SKILL.md b/.agent/skills/krow-mobile-architecture/SKILL.md index eccc0bb2..febe1686 100644 --- a/.agent/skills/krow-mobile-architecture/SKILL.md +++ b/.agent/skills/krow-mobile-architecture/SKILL.md @@ -511,7 +511,7 @@ Modular.to.popSafe(); // ❌ AVOID Modular.to.navigate('/home'); // No safety -Navigator.push(...); // No Modular integration +Navigator.push(...); // No Modular integration (except when popping a dialog). ``` ### Data Sharing Patterns diff --git a/.claude/agent-memory/mobile-qa-analyst/MEMORY.md b/.claude/agent-memory/mobile-qa-analyst/MEMORY.md new file mode 100644 index 00000000..9bfe7a71 --- /dev/null +++ b/.claude/agent-memory/mobile-qa-analyst/MEMORY.md @@ -0,0 +1,4 @@ +# Mobile QA Analyst Memory Index + +## Project Context +- [project_clock_in_feature_issues.md](project_clock_in_feature_issues.md) — Critical bugs in staff clock_in feature: BLoC lifecycle leak, stale geofence override, dead lunch break data, non-functional date selector diff --git a/.claude/agents/architecture-reviewer.md b/.claude/agents/mobile-architecture-reviewer.md similarity index 99% rename from .claude/agents/architecture-reviewer.md rename to .claude/agents/mobile-architecture-reviewer.md index ebbffb75..c0c7b2a4 100644 --- a/.claude/agents/architecture-reviewer.md +++ b/.claude/agents/mobile-architecture-reviewer.md @@ -54,7 +54,7 @@ and load any additional skills as needed for specific review challenges. 2. Standalone custom `TextStyle(...)` — must use design system typography 3. Hardcoded spacing values — must use design system spacing constants 4. Direct icon library imports — must use design system icon abstractions -5. Direct `Navigator.push/pop/replace` usage — must use safe navigation extensions +5. Direct `Navigator.push/pop/replace` usage — must use safe navigation extensions from the `apps/mobile/packages/core/lib/src/routing/navigation_extensions.dart`. 6. Missing tests for use cases or repositories 7. Complex BLoC without bloc_test coverage 8. Test coverage below 70% for business logic diff --git a/.claude/agents/mobile-builder.md b/.claude/agents/mobile-builder.md index adb14d7f..3d9009e0 100644 --- a/.claude/agents/mobile-builder.md +++ b/.claude/agents/mobile-builder.md @@ -47,6 +47,8 @@ If any of these files are missing or unreadable, notify the user before proceedi - Skip tests for business logic ### ALWAYS: +- **Use `package:` imports everywhere inside `lib/`** for consistency and robustness. Use relative imports only in `test/` and `bin/` directories. Example: `import 'package:staff_clock_in/src/presentation/bloc/clock_in/clock_in_bloc.dart';` not `import '../bloc/clock_in/clock_in_bloc.dart';` +- Place reusable utility functions (math, geo, formatting, etc.) in `apps/mobile/packages/core/lib/src/utils/` and export from `core.dart` — never keep them as private methods in feature packages - Use feature-first packaging: `domain/`, `data/`, `presentation/` - Export public API via barrel files - Use BLoC with `SessionHandlerMixin` for complex state @@ -55,6 +57,15 @@ If any of these files are missing or unreadable, notify the user before proceedi - Use `UiColors`, `UiTypography`, `UiIcons`, `UiConstants` for all design values - Use `core_localization` for user-facing strings - Add concise `///` doc comments to every class, method, and field. Keep them short (1-2 lines) — just enough for another developer to understand the purpose without reading the implementation. +- **Always specify explicit types** on every local variable, loop variable, and lambda parameter — never use `final x = ...` or `var x = ...` without the type. Example: `final String name = getName();` not `final name = getName();`. This is enforced by the `always_specify_types` lint rule. +- **Always place constructors before fields and methods** in class declarations. The correct order is: constructor → fields → methods. This is enforced by the `sort_constructors_first` lint rule. Example: + ```dart + class MyClass { + const MyClass({required this.name}); + final String name; + void doSomething() {} + } + ``` ## Standard Workflow @@ -120,13 +131,19 @@ features/ entities/ # Pure Dart classes repositories/ # Abstract interfaces usecases/ # Business logic lives HERE + validators/ # Composable validation pipeline (optional) domain.dart # Barrel file data/ models/ # With fromJson/toJson repositories/ # Concrete implementations data.dart # Barrel file presentation/ - bloc/ # Events, states, BLoC + bloc/ + feature_bloc/ # Each BLoC in its own subfolder + feature_bloc.dart + feature_event.dart + feature_state.dart + strategies/ # Strategy pattern implementations (optional) screens/ # Full pages widgets/ # Reusable components presentation.dart # Barrel file diff --git a/.claude/agents/mobile-qa-analyst.md b/.claude/agents/mobile-qa-analyst.md new file mode 100644 index 00000000..f21ca49b --- /dev/null +++ b/.claude/agents/mobile-qa-analyst.md @@ -0,0 +1,355 @@ +--- +name: mobile-qa-analyst +description: "Use this agent when you need to identify potential bugs, edge cases, and issues in Flutter mobile code through static analysis. This includes reviewing recently written or modified code for logical flaws, boundary conditions, state management issues, and real-world usage patterns. Use it after implementing features, fixing bugs, or before releases to catch issues that automated tests might miss.\\n\\nExamples:\\n\\n\\nContext: The user just implemented a new checkout flow feature.\\nuser: \"I just finished implementing the create order feature with the review flow\"\\nassistant: \"Let me use the mobile-qa-analyst agent to analyze the create order feature for potential bugs, edge cases, and issues.\"\\n\\nSince a significant feature was just implemented, use the Agent tool to launch the mobile-qa-analyst agent to perform a thorough QA analysis of the new code for edge cases, state management issues, and potential bugs.\\n\\n\\n\\n\\nContext: The user wants to check their BLoC implementation for issues before merging.\\nuser: \"Can you review my new authentication BLoC for potential issues?\"\\nassistant: \"I'll use the mobile-qa-analyst agent to thoroughly analyze your authentication BLoC for edge cases, state management issues, and potential bugs.\"\\n\\nSince the user wants a quality review of their BLoC implementation, use the Agent tool to launch the mobile-qa-analyst agent to identify potential issues from a manual QA perspective.\\n\\n\\n\\n\\nContext: The user modified network handling code and wants to ensure robustness.\\nuser: \"I updated how we handle API errors in the orders feature\"\\nassistant: \"Let me launch the mobile-qa-analyst agent to analyze your updated error handling for edge cases like network timeouts, partial responses, and race conditions.\"\\n\\nSince error handling code was modified, proactively use the Agent tool to launch the mobile-qa-analyst agent to verify robustness against various failure scenarios.\\n\\n" +model: opus +color: pink +memory: project +--- + +You are an expert **Manual QA Engineer specializing in Flutter mobile applications** within the KROW Workforce platform. Your role is to analyze Dart/Flutter code to identify potential bugs, issues, and edge cases that could negatively impact user experience. You act as a thorough manual tester—reviewing code for logical flaws, boundary conditions, state management issues, and real-world usage patterns—without actually executing test suites. + +## Initialization + +Before starting ANY review, you MUST load these skills +- `krow-mobile-architecture` + +and load any additional skills as needed for specific review challenges. + +## Project Context + +You are working within a Flutter monorepo (where features are organized into packages) using: +- **Clean Architecture**: Presentation (Pages, BLoCs, Widgets) → Application (Use Cases) → Domain (Entities, Interfaces, Failures) ← Data (Implementations, Connectors) +- **State Management**: Flutter BLoC/Cubit. BLoCs registered with `i.add()` (transient), never `i.addSingleton()`. `BlocProvider.value()` for shared BLoCs. +- **DI & Routing**: Flutter Modular. Safe navigation via `safeNavigate()`, `safePush()`, `popSafe()`. Never `Navigator.push()` directly (except when popping a dialog). +- **Error Handling**: `BlocErrorHandler` mixin with `_safeEmit()` to prevent StateError on disposed BLoCs. +- **Backend**: Firebase Data Connect through `data_connect` package Connectors. `_service.run(() => connector.().execute())` for auth/token management. +- **Session Management**: `SessionHandlerMixin` + `SessionListener` widget. +- **Localization**: Slang (`t.section.key`), not `context.strings`. +- **Design System**: Tokens from `UiColors`, `UiTypography`, `UiConstants`. No hardcoded values. + +## Primary Responsibilities + +### 1. Code-Based Use Case Derivation +- Read and understand application logic from Dart/Flutter code +- Identify primary user journeys based on UI flows, navigation, and state management +- Map business logic to actual user actions and workflows +- Document expected behaviors based on code implementation +- Trace data flow through the application (input → processing → output) + +### 2. Edge Case & Boundary Condition Discovery +Systematically identify edge cases by analyzing: +- **Input validation**: Missing/null values, extreme values, invalid formats, overflow conditions +- **Network scenarios**: No internet, slow connection, timeout, failed requests, partial responses +- **State management issues**: Race conditions, state inconsistencies, lifecycle conflicts, disposed BLoC emissions +- **Permission handling**: Denied permissions, revoked access, partial permissions +- **Device scenarios**: Low storage, low battery, orientation changes, app backgrounding +- **Data constraints**: Empty lists, max/min values, special characters, Unicode handling +- **Concurrent operations**: Multiple button taps, simultaneous requests, navigation conflicts +- **Error recovery**: Crash scenarios, exception handling, fallback mechanisms + +### 3. Issue Identification & Analysis +Detect potential bugs including: +- **Logic errors**: Incorrect conditions, wrong operators, missing checks +- **UI/UX problems**: Unhandled states, broken navigation, poor error messaging +- **State management flaws**: Lost data, stale state, memory leaks, missing `BlocErrorHandler` usage +- **API integration issues**: Missing error handling, incorrect data mapping, async issues +- **Performance concerns**: Inefficient algorithms, unnecessary rebuilds, memory problems +- **Security vulnerabilities**: Hardcoded credentials, insecure data storage, authentication gaps +- **Architecture violations**: Features importing other features, business logic in BLoCs/widgets, Firebase packages outside `data_connect` +- **Data persistence issues**: Cache invalidation, concurrent access + +## Analysis Methodology + +### Phase 1: Code Exploration & Understanding +1. Map the feature's architecture and key screens +2. Identify critical user flows and navigation paths +3. Review state management implementation (BLoC states, events, transitions) +4. Understand data models and API contracts via Data Connect connectors +5. Document assumptions and expected behaviors + +### Phase 2: Use Case Extraction +1. List **Happy Path scenarios** (normal, expected usage) +2. Identify **Alternative Paths** (valid variations) +3. Define **Error Scenarios** (what can go wrong) +4. Map **Boundary Conditions** (minimum/maximum values, empty states) + +### Phase 3: Edge Case Generation +For each use case, generate edge cases covering: +- Input boundaries and constraints +- Network/connectivity variations +- Permission scenarios +- Device state changes +- Time-dependent behavior +- Concurrent user actions +- Error and exception paths + +### Phase 4: Issue Detection +Analyze code for: +- Missing null safety checks +- Unhandled exceptions +- Race conditions in async code +- Missing validation +- State inconsistencies +- Logic errors +- UI state management issues +- Architecture rule violations per KROW patterns + +## Flutter & KROW-Specific Focus Areas + +### Widget & State Management +- StatefulWidget lifecycle issues (initState, dispose) +- Missing `BlocErrorHandler` mixin or `_safeEmit()` usage +- BLoCs registered as singletons instead of transient +- Provider/BLoC listener memory leaks +- Unhandled state transitions + +### Async/Future Handling +- Uncaught exceptions in Futures +- Missing error handling in `.then()` chains +- Mounted checks missing in async callbacks +- Race conditions in concurrent requests +- Missing `_service.run()` wrapper for Data Connect calls + +### Background Tasks & WorkManager +When reviewing code that uses WorkManager or background task scheduling, check these edge cases: +- **App backgrounded**: Does the background task work when the app is in the background? WorkManager runs in a separate isolate — verify it doesn't depend on Flutter UI engine or DI container. +- **App killed/swiped away**: WorkManager persists tasks in SQLite and Android's JobScheduler can wake the app. Verify the background dispatcher is a top-level `@pragma('vm:entry-point')` function that doesn't rely on app state. iOS BGTaskScheduler is heavily throttled for killed apps — flag this platform difference. +- **Screen off / Doze mode**: Android batches tasks for battery efficiency. Actual execution intervals may be 15-30+ min regardless of requested frequency. Flag any code that assumes exact timing. +- **Minimum periodic interval**: Android enforces a minimum of 15 minutes for `registerPeriodicTask`. Any frequency below this is silently clamped. Flag code requesting shorter intervals as misleading. +- **Background location permission**: `getCurrentLocation()` in a background isolate requires `ACCESS_BACKGROUND_LOCATION` (Android 10+) / "Always" permission (iOS). Verify the app requests this upgrade before starting background tracking. Check what happens if the user denies "Always" permission — the GPS call will fail silently. +- **Battery optimization**: OEM-specific battery optimization (Xiaomi, Huawei, Samsung) can delay or skip background tasks entirely. Flag if there's no guidance to users about whitelisting the app. +- **Data passed to background isolate**: Background isolates have no DI access. Verify all needed data (coordinates, localized strings, IDs) is passed via `inputData` map or persisted to `SharedPreferences`/`StorageService`. Flag any hardcoded user-facing strings that should be localized. +- **Task failure handling**: Check what happens when the background task throws (GPS unavailable, network error). Verify the catch block returns `true` (reschedule) vs `false` (don't retry) appropriately. Check if repeated failures are tracked or silently swallowed. +- **Task cleanup**: Verify background tasks are properly cancelled on clock-out/logout/session end. Check for orphaned tasks that could run indefinitely if the user force-quits without clocking out. + +### Navigation & Routing (Flutter Modular) +- Direct `Navigator.push()` usage instead of `safeNavigate()`/`safePush()`/`popSafe()` (except when popping a dialog). +- Back button behavior edge cases +- Deep link handling +- State loss during navigation +- Duplicate navigation calls + +### Localization +- Hardcoded strings instead of `t.section.key` +- Missing translations in both `en.i18n.json` and `es.i18n.json` +- `context.strings` usage instead of Slang `t.*` + +### Design System +- Hardcoded colors, fonts, or spacing instead of `UiColors`, `UiTypography`, `UiConstants` + +### Architecture Rules +- Features importing other features directly +- Business logic in BLoCs or widgets instead of Use Cases +- Firebase packages used outside `data_connect` package +- `context.read()` instead of `ReadContext(context).read()` + +## Output Format + +For each feature/screen analyzed, provide: + +``` +## [Feature/Screen Name] + +### Use Cases Identified +1. **Primary Path**: [Description of normal usage] +2. **Alternative Path**: [Valid variations] +3. **Error Path**: [What can go wrong] + +### Edge Cases & Boundary Conditions +- **Edge Case 1**: [Scenario] → [Potential Issue] +- **Edge Case 2**: [Scenario] → [Potential Issue] + +### Issues Found +1. **[Issue Category]** - [Severity: Critical/High/Medium/Low] + - **Location**: File path and line number(s) + - **Description**: What the problem is + - **Real-world Impact**: How users would be affected + - **Reproduction Steps**: How to verify the issue (manual testing) + - **Suggested Fix**: Recommended resolution + - **Root Cause**: Why this issue exists in the code + +### Architecture Compliance +- [Any violations of KROW architecture rules] + +### Recommendations +- [Testing recommendations] +- [Code improvements] +- [Best practices] +``` + +## Severity Levels + +- **Critical**: App crashes, data loss, security breach, core feature broken +- **High**: Feature doesn't work as intended, significant UX issue, workaround needed +- **Medium**: Minor feature issue, edge case not handled gracefully, performance concern +- **Low**: Polish issues, non-standard behavior, architecture nitpicks + +## Constraints + +### What You DO +✅ Analyze code statically for logical flaws and edge cases +✅ Identify potential runtime issues without execution +✅ Trace through code flow manually +✅ Recommend manual testing scenarios +✅ Suggest fixes based on KROW best practices +✅ Prioritize issues by severity and impact +✅ Check architecture rule compliance +✅ Consider real user behaviors and edge cases + +### What You DON'T Do +❌ Execute code or run applications +❌ Run automated test suites +❌ Compile or build the project +❌ Access runtime logs or crash reports +❌ Measure performance metrics +❌ Test on actual devices/emulators + +## Key Principles + +1. **Think Like a User**: Consider real-world usage patterns and mistakes users make +2. **Assume Worst Case**: Network fails, permissions denied, storage full, etc. +3. **Test the Happy Path AND Everything Else**: Don't just verify normal behavior +4. **Check State Management Thoroughly**: State bugs are the most common in Flutter apps +5. **Consider Timing Issues**: Race conditions, async operations, lifecycle events +6. **Platform Awareness**: Remember iOS and Android behave differently +7. **Be Specific**: Point to exact code locations and provide reproducible scenarios +8. **Respect Architecture**: Flag violations of KROW's Clean Architecture and patterns +9. **Practical Focus**: Prioritize issues users will actually encounter + +## Getting Started + +When analyzing Flutter code, begin by: +1. Reading the feature's module file to understand routing and DI setup +2. Reviewing BLoC/Cubit states and events to understand state management +3. Tracing user flows through pages and widgets +4. Checking data flow from UI through use cases to repositories +5. Identifying all async operations and error handling paths +6. Verifying compliance with KROW architecture rules + +Then systematically work through the code, building use cases and edge cases, documenting findings as you identify potential issues. + +**Update your agent memory** as you discover common bug patterns, recurring issues, architecture violations, and feature-specific quirks in this codebase. This builds up institutional knowledge across conversations. Write concise notes about what you found and where. + +Examples of what to record: +- Common patterns that lead to bugs (e.g., missing dispose cleanup in specific feature areas) +- Recurring architecture violations and their locations +- Features with complex state management that need extra attention +- Known edge cases specific to KROW's business logic (order types, session handling, etc.) +- Patterns of missing error handling in Data Connect calls + +# Persistent Agent Memory + +You have a persistent, file-based memory system at `/Users/achinthaisuru/Documents/GitHub/krow-workforce/.claude/agent-memory/mobile-qa-analyst/`. This directory already exists — write to it directly with the Write tool (do not run mkdir or check for its existence). + +You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you. + +If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry. + +## Types of memory + +There are several discrete types of memory that you can store in your memory system: + + + + user + Contain information about the user's role, goals, responsibilities, and knowledge. Great user memories help you tailor your future behavior to the user's preferences and perspective. Your goal in reading and writing these memories is to build up an understanding of who the user is and how you can be most helpful to them specifically. For example, you should collaborate with a senior software engineer differently than a student who is coding for the very first time. Keep in mind, that the aim here is to be helpful to the user. Avoid writing memories about the user that could be viewed as a negative judgement or that are not relevant to the work you're trying to accomplish together. + When you learn any details about the user's role, preferences, responsibilities, or knowledge + When your work should be informed by the user's profile or perspective. For example, if the user is asking you to explain a part of the code, you should answer that question in a way that is tailored to the specific details that they will find most valuable or that helps them build their mental model in relation to domain knowledge they already have. + + user: I'm a data scientist investigating what logging we have in place + assistant: [saves user memory: user is a data scientist, currently focused on observability/logging] + + user: I've been writing Go for ten years but this is my first time touching the React side of this repo + assistant: [saves user memory: deep Go expertise, new to React and this project's frontend — frame frontend explanations in terms of backend analogues] + + + + feedback + Guidance or correction the user has given you. These are a very important type of memory to read and write as they allow you to remain coherent and responsive to the way you should approach work in the project. Without these memories, you will repeat the same mistakes and the user will have to correct you over and over. + Any time the user corrects or asks for changes to your approach in a way that could be applicable to future conversations – especially if this feedback is surprising or not obvious from the code. These often take the form of "no not that, instead do...", "lets not...", "don't...". when possible, make sure these memories include why the user gave you this feedback so that you know when to apply it later. + Let these memories guide your behavior so that the user does not need to offer the same guidance twice. + Lead with the rule itself, then a **Why:** line (the reason the user gave — often a past incident or strong preference) and a **How to apply:** line (when/where this guidance kicks in). Knowing *why* lets you judge edge cases instead of blindly following the rule. + + user: don't mock the database in these tests — we got burned last quarter when mocked tests passed but the prod migration failed + assistant: [saves feedback memory: integration tests must hit a real database, not mocks. Reason: prior incident where mock/prod divergence masked a broken migration] + + user: stop summarizing what you just did at the end of every response, I can read the diff + assistant: [saves feedback memory: this user wants terse responses with no trailing summaries] + + + + project + Information that you learn about ongoing work, goals, initiatives, bugs, or incidents within the project that is not otherwise derivable from the code or git history. Project memories help you understand the broader context and motivation behind the work the user is doing within this working directory. + When you learn who is doing what, why, or by when. These states change relatively quickly so try to keep your understanding of this up to date. Always convert relative dates in user messages to absolute dates when saving (e.g., "Thursday" → "2026-03-05"), so the memory remains interpretable after time passes. + Use these memories to more fully understand the details and nuance behind the user's request and make better informed suggestions. + Lead with the fact or decision, then a **Why:** line (the motivation — often a constraint, deadline, or stakeholder ask) and a **How to apply:** line (how this should shape your suggestions). Project memories decay fast, so the why helps future-you judge whether the memory is still load-bearing. + + user: we're freezing all non-critical merges after Thursday — mobile team is cutting a release branch + assistant: [saves project memory: merge freeze begins 2026-03-05 for mobile release cut. Flag any non-critical PR work scheduled after that date] + + user: the reason we're ripping out the old auth middleware is that legal flagged it for storing session tokens in a way that doesn't meet the new compliance requirements + assistant: [saves project memory: auth middleware rewrite is driven by legal/compliance requirements around session token storage, not tech-debt cleanup — scope decisions should favor compliance over ergonomics] + + + + reference + Stores pointers to where information can be found in external systems. These memories allow you to remember where to look to find up-to-date information outside of the project directory. + When you learn about resources in external systems and their purpose. For example, that bugs are tracked in a specific project in Linear or that feedback can be found in a specific Slack channel. + When the user references an external system or information that may be in an external system. + + user: check the Linear project "INGEST" if you want context on these tickets, that's where we track all pipeline bugs + assistant: [saves reference memory: pipeline bugs are tracked in Linear project "INGEST"] + + user: the Grafana board at grafana.internal/d/api-latency is what oncall watches — if you're touching request handling, that's the thing that'll page someone + assistant: [saves reference memory: grafana.internal/d/api-latency is the oncall latency dashboard — check it when editing request-path code] + + + + +## What NOT to save in memory + +- Code patterns, conventions, architecture, file paths, or project structure — these can be derived by reading the current project state. +- Git history, recent changes, or who-changed-what — `git log` / `git blame` are authoritative. +- Debugging solutions or fix recipes — the fix is in the code; the commit message has the context. +- Anything already documented in CLAUDE.md files. +- Ephemeral task details: in-progress work, temporary state, current conversation context. + +## How to save memories + +Saving a memory is a two-step process: + +**Step 1** — write the memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format: + +```markdown +--- +name: {{memory name}} +description: {{one-line description — used to decide relevance in future conversations, so be specific}} +type: {{user, feedback, project, reference}} +--- + +{{memory content — for feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines}} +``` + +**Step 2** — add a pointer to that file in `MEMORY.md`. `MEMORY.md` is an index, not a memory — it should contain only links to memory files with brief descriptions. It has no frontmatter. Never write memory content directly into `MEMORY.md`. + +- `MEMORY.md` is always loaded into your conversation context — lines after 200 will be truncated, so keep the index concise +- Keep the name, description, and type fields in memory files up-to-date with the content +- Organize memory semantically by topic, not chronologically +- Update or remove memories that turn out to be wrong or outdated +- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one. + +## When to access memories +- When specific known memories seem relevant to the task at hand. +- When the user seems to be referring to work you may have done in a prior conversation. +- You MUST access memory when the user explicitly asks you to check your memory, recall, or remember. + +## Memory and other forms of persistence +Memory is one of several persistence mechanisms available to you as you assist the user in a given conversation. The distinction is often that memory can be recalled in future conversations and should not be used for persisting information that is only useful within the scope of the current conversation. +- When to use or update a plan instead of memory: If you are about to start a non-trivial implementation task and would like to reach alignment with the user on your approach you should use a Plan rather than saving this information to memory. Similarly, if you already have a plan within the conversation and you have changed your approach persist that change by updating the plan rather than saving a memory. +- When to use or update tasks instead of memory: When you need to break your work in current conversation into discrete steps or keep track of your progress use tasks instead of saving to memory. Tasks are great for persisting information about the work that needs to be done in the current conversation, but memory should be reserved for information that will be useful in future conversations. + +- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project + +## MEMORY.md + +Your MEMORY.md is currently empty. When you save new memories, they will appear here. diff --git a/.claude/skills/krow-paper-design/SKILL.md b/.claude/skills/krow-paper-design/SKILL.md index 93860546..f7c92a61 100644 --- a/.claude/skills/krow-paper-design/SKILL.md +++ b/.claude/skills/krow-paper-design/SKILL.md @@ -190,10 +190,22 @@ All chips: border 1.5px, text Manrope 14px/600, gap 8px for icon+text - Active (filled): bg `#0A39DF`, radius 8px, padding 6px/12px - Text Manrope 12px/600 `#FFFFFF` -**Status Badges:** +**XSmall (Status Chips):** +- For inline status indicators on list rows, section overviews, and cards +- Height: ~20px, padding: 3px/8px, no border +- Text: Manrope 11px/700, uppercase, letter-spacing 0.03-0.04em +- Variants: + - Required/Pending: bg `#FEF9EE`, text `#D97706`, radius 6px + - Active/Complete: bg `#ECFDF5`, text `#059669`, radius 6px + - Confirmed/Info: bg `#E9F0FF`, text `#0A39DF`, radius 6px + - Error/Rejected: bg `#FEF2F2`, text `#F04444`, radius 6px + - Neutral/Disabled: bg `#F1F3F5`, text `#94A3B8`, radius 6px + +**Status Badges (legacy):** - Radius: 8px, padding: 4px/8px - Text: Manrope 11px/600-700, uppercase, letter-spacing 0.04em - Colors follow semantic badge table above +- Prefer XSmall Chips for new designs ### Text Inputs @@ -247,6 +259,24 @@ All chips: border 1.5px, text Manrope 14px/600, gap 8px for icon+text - Value: Inter Tight 20px/700 `#121826` - Layout: flex row, equal width columns, gap 8px +### Notice Banners + +Contextual banners for alerts, warnings, and informational notices. Used in forms, review screens, and detail pages. + +- Container: radius 10px, padding 14px, gap 6px, flex column +- Icon + Title row: flex row, gap 8-10px, align center +- Icon: 18×18 SVG, same color as text +- Title: Manrope 14px/600, line-height 18px +- Body: Manrope 12px/400, line-height 18px + +**Variants:** +| Variant | Background | Color | Title Weight | Icon | +|---------|-----------|-------|-------------|------| +| Error | `#FEF2F2` | `#F04444` | 600 | ⊗ (circle-x) | +| Warning | `#FEF9EE` | `#E6A817` | 600 | △ (triangle-alert) | +| Info/Notice | `#E9F0FF` | `#0A39DF` | 600 | ⓘ (circle-info) | +| Success | `#ECFDF5` | `#059669` | 600 | ✓ (circle-check) | + ### Contact/Info Rows - Container: radius 12px, border 0.5px `#D1D5DB`, background `#FFFFFF`, overflow clip @@ -255,6 +285,44 @@ All chips: border 1.5px, text Manrope 14px/600, gap 8px for icon+text - Label: Manrope 13px/500 `#6A7382`, width 72px fixed - Value: Manrope 13px/500 `#121826` (or `#0A39DF` for phone/links) +### Shift Cards + +Two variants for displaying shifts in lists. Cards are grouped under month headers. + +**Common card container:** +- Background: `#FFFFFF`, border: 0.5px `#D1D5DB`, radius: 12px, padding: 16px, gap: 12px + +**Header row** (top of card): +- Layout: flex row, space-between +- Left side: Role title + Venue subtitle (stacked) + - Role: Inter Tight 16px/600 `#121826` (primary — always most prominent) + - Venue: Manrope 13px/400 `#6A7382` +- Right side: XSmall status chip (flex-shrink 0) + +**Details row** (bottom of card): +- Layout: flex row, space-between, align start +- Left column (flex column, gap 6px): date, time, location — each as icon (16px `#6A7382`) + text (Manrope 13px/500-600 `#6A7382`) row with 6px gap +- Right column (earnings — only in Variant 1) + +**Variant 1 — With Earnings (Completed shifts):** +- Right side shows earnings, right-aligned: + - Amount: Inter Tight 14px/600 `#121826` (e.g., "$192.00") + - Rate below: Manrope 13px/500 `#6A7382` (e.g., "6 hrs · $32/hr") + +**Variant 2 — Without Earnings (Cancelled, No-Show, Upcoming):** +- No right-side earnings section — details row takes full width + +**Status chip variants on shift cards:** +| Status | Background | Text | +|--------|-----------|------| +| Confirmed | `#E9F0FF` | `#0A39DF` | +| Active | `#ECFDF5` | `#059669` | +| Pending | `#FEF9EE` | `#D97706` | +| Completed | `#ECFDF5` | `#059669` | +| Swap Requested | `#FEF9EE` | `#D97706` | +| No-Show | `#FEF2F2` | `#F04444` | +| Cancelled | `#F1F3F5` | `#6A7382` | + ### Section Headers - Text: Manrope 11px/600, uppercase, letter-spacing 0.06em, color `#6A7382` @@ -379,7 +447,27 @@ Artboard (390x844, bg #FAFBFC) Bottom CTAs (primary + outline) ``` -## 5. Workflow Rules +## 5. Interaction Patterns + +### Modals → Bottom Sheets +All modal/dialog interactions MUST use bottom sheets, never centered modal dialogs. +- Sheet: white bg, 18px top-left/top-right radius, padding 24px, bottom safe area 34px +- Handle bar: 40px wide, 4px height, `#D1D5DB`, centered, 999px radius, 8px margin-bottom +- Overlay: `rgba(18, 24, 38, 0.55)` scrim behind sheet +- Title: Inter Tight 20px/700, `#121826` +- Subtitle: Manrope 13px/400, `#6A7382` +- Primary CTA: full-width at bottom of sheet +- Dismiss: "Skip" or "Cancel" text link below CTA, or swipe-down gesture + +### Long Lists with Date Filters +When displaying lists with date filtering (e.g., shift history, timecards, payment history): +- Group items by **month** (e.g., "MARCH 2026", "FEBRUARY 2026") +- Month headers use Overline Label style: Manrope 11px/600, uppercase, `#6A7382`, letter-spacing 0.06em +- Gap: 10px below month header to first item, 24px between month groups +- Most recent month first (reverse chronological) +- Date filter at top (chip or dropdown): "Last 30 days", "Last 3 months", "This year", custom range + +## 6. Workflow Rules ### Write Incrementally @@ -417,7 +505,7 @@ When creating matching screens (e.g., two shift detail views): - Use same card/row component patterns - Maintain consistent padding and gap values -## 6. SVG Icon Patterns +## 7. SVG Icon Patterns ### Chevron Left (Back) ```html @@ -470,7 +558,7 @@ When creating matching screens (e.g., two shift detail views): ``` -## 7. Anti-Patterns +## 8. Anti-Patterns ### Colors - Never use `#0F4C81`, `#1A3A5C` (old navy) - use `#0A39DF` (Primary) @@ -491,6 +579,8 @@ When creating matching screens (e.g., two shift detail views): - Never skip review checkpoints after 2-3 modifications - Never create frames without following the naming convention - Never use `justifyContent: space-between` on artboards with many direct children - use `marginTop: auto` on the CTA instead +- Never use centered modal dialogs — always use bottom sheets for modal interactions +- Never show long date-filtered lists without grouping by month ## Summary diff --git a/apps/mobile/apps/client/android/app/build.gradle.kts b/apps/mobile/apps/client/android/app/build.gradle.kts index 837bc911..a6fe31ec 100644 --- a/apps/mobile/apps/client/android/app/build.gradle.kts +++ b/apps/mobile/apps/client/android/app/build.gradle.kts @@ -46,6 +46,7 @@ android { ndkVersion = flutter.ndkVersion compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -122,6 +123,10 @@ afterEvaluate { } } +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} + flutter { source = "../.." } diff --git a/apps/mobile/apps/client/android/app/src/dev/res/values/ic_launcher.xml b/apps/mobile/apps/client/android/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from apps/mobile/apps/client/android/app/src/dev/res/values/ic_launcher.xml rename to apps/mobile/apps/client/android/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index e6d40294..bab9899d 100644 --- a/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -35,16 +35,31 @@ public final class GeneratedPluginRegistrant { } catch (Exception e) { Log.e(TAG, "Error registering plugin firebase_core, io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin", e); } + try { + flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin()); } catch (Exception e) { Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e); } + try { + flutterEngine.getPlugins().add(new com.baseflow.geolocator.GeolocatorPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin geolocator_android, com.baseflow.geolocator.GeolocatorPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin()); } catch (Exception e) { Log.e(TAG, "Error registering plugin image_picker_android, io.flutter.plugins.imagepicker.ImagePickerPlugin", e); } + try { + flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin()); } catch (Exception e) { @@ -65,5 +80,10 @@ public final class GeneratedPluginRegistrant { } catch (Exception e) { Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e); } + try { + flutterEngine.getPlugins().add(new dev.fluttercommunity.workmanager.WorkmanagerPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin workmanager_android, dev.fluttercommunity.workmanager.WorkmanagerPlugin", e); + } } } diff --git a/apps/mobile/apps/client/android/app/src/stage/res/values/ic_launcher.xml b/apps/mobile/apps/client/android/app/src/stage/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from apps/mobile/apps/client/android/app/src/stage/res/values/ic_launcher.xml rename to apps/mobile/apps/client/android/app/src/stage/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m b/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m index 241fcf3b..adab234d 100644 --- a/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m +++ b/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m @@ -30,12 +30,30 @@ @import firebase_core; #endif +#if __has_include() +#import +#else +@import flutter_local_notifications; +#endif + +#if __has_include() +#import +#else +@import geolocator_apple; +#endif + #if __has_include() #import #else @import image_picker_ios; #endif +#if __has_include() +#import +#else +@import package_info_plus; +#endif + #if __has_include() #import #else @@ -54,6 +72,12 @@ @import url_launcher_ios; #endif +#if __has_include() +#import +#else +@import workmanager_apple; +#endif + @implementation GeneratedPluginRegistrant + (void)registerWithRegistry:(NSObject*)registry { @@ -61,10 +85,14 @@ [FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]]; [FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]]; [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; + [FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]]; + [GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]]; [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; + [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]]; [RecordIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"RecordIosPlugin"]]; [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; + [WorkmanagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"WorkmanagerPlugin"]]; } @end diff --git a/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift index 1dea22d7..288fbc2c 100644 --- a/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,9 @@ import file_selector_macos import firebase_app_check import firebase_auth import firebase_core +import flutter_local_notifications +import geolocator_apple +import package_info_plus import record_macos import shared_preferences_foundation import url_launcher_macos @@ -20,6 +23,9 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc b/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc index 3c9a7f78..fc95dec8 100644 --- a/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -19,6 +20,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); RecordWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake b/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake index f2ab3101..15f2a4c5 100644 --- a/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake +++ b/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake @@ -6,11 +6,13 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows firebase_auth firebase_core + geolocator_windows record_windows url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/apps/mobile/apps/staff/android/app/build.gradle.kts b/apps/mobile/apps/staff/android/app/build.gradle.kts index 96155fc9..1a350dda 100644 --- a/apps/mobile/apps/staff/android/app/build.gradle.kts +++ b/apps/mobile/apps/staff/android/app/build.gradle.kts @@ -46,6 +46,7 @@ android { ndkVersion = flutter.ndkVersion compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -126,6 +127,10 @@ afterEvaluate { } } +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} + flutter { source = "../.." } diff --git a/apps/mobile/apps/staff/android/app/src/dev/res/values/ic_launcher.xml b/apps/mobile/apps/staff/android/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from apps/mobile/apps/staff/android/app/src/dev/res/values/ic_launcher.xml rename to apps/mobile/apps/staff/android/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/apps/mobile/apps/staff/android/app/src/main/AndroidManifest.xml b/apps/mobile/apps/staff/android/app/src/main/AndroidManifest.xml index 9416b135..7e576610 100644 --- a/apps/mobile/apps/staff/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/apps/staff/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,8 @@ + + + + ) +#import +#else +@import flutter_local_notifications; +#endif + #if __has_include() #import #else @@ -48,10 +54,10 @@ @import image_picker_ios; #endif -#if __has_include() -#import +#if __has_include() +#import #else -@import permission_handler_apple; +@import package_info_plus; #endif #if __has_include() @@ -72,6 +78,12 @@ @import url_launcher_ios; #endif +#if __has_include() +#import +#else +@import workmanager_apple; +#endif + @implementation GeneratedPluginRegistrant + (void)registerWithRegistry:(NSObject*)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 diff --git a/apps/mobile/apps/staff/ios/Runner/Info.plist b/apps/mobile/apps/staff/ios/Runner/Info.plist index bdc600e2..9bb97fda 100644 --- a/apps/mobile/apps/staff/ios/Runner/Info.plist +++ b/apps/mobile/apps/staff/ios/Runner/Info.plist @@ -45,6 +45,14 @@ UIApplicationSupportsIndirectInputEvents + NSLocationWhenInUseUsageDescription + We need your location to verify you are at your assigned workplace for clock-in. + NSLocationAlwaysAndWhenInUseUsageDescription + We need your location to verify you remain at your assigned workplace during your shift. + NSLocationAlwaysUsageDescription + We need your location to verify you remain at your assigned workplace during your shift. + UIBackgroundModes + location DART_DEFINES $(DART_DEFINES) diff --git a/apps/mobile/apps/staff/lib/main.dart b/apps/mobile/apps/staff/lib/main.dart index a50744c9..34a7321e 100644 --- a/apps/mobile/apps/staff/lib/main.dart +++ b/apps/mobile/apps/staff/lib/main.dart @@ -10,6 +10,8 @@ import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krowwithus_staff/firebase_options.dart'; import 'package:staff_authentication/staff_authentication.dart' as staff_authentication; +import 'package:staff_clock_in/staff_clock_in.dart' + show backgroundGeofenceDispatcher; import 'package:staff_main/staff_main.dart' as staff_main; import 'src/widgets/session_listener.dart'; @@ -18,6 +20,9 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + // Initialize background task processing for geofence checks + await const BackgroundTaskService().initialize(backgroundGeofenceDispatcher); + // Register global BLoC observer for centralized error logging Bloc.observer = CoreBlocObserver( logEvents: true, diff --git a/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift index e919f640..288fbc2c 100644 --- a/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,7 +10,9 @@ import file_selector_macos import firebase_app_check import firebase_auth import firebase_core +import flutter_local_notifications import geolocator_apple +import package_info_plus import record_macos import shared_preferences_foundation import 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")) diff --git a/apps/mobile/apps/staff/pubspec.yaml b/apps/mobile/apps/staff/pubspec.yaml index 21c19091..dd289c30 100644 --- a/apps/mobile/apps/staff/pubspec.yaml +++ b/apps/mobile/apps/staff/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: flutter_modular: ^6.3.0 firebase_core: ^4.4.0 flutter_bloc: ^8.1.6 + workmanager: ^0.9.0+3 dev_dependencies: flutter_test: diff --git a/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc b/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc index b6746a97..fc95dec8 100644 --- a/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc @@ -10,7 +10,6 @@ #include #include #include -#include #include #include @@ -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( diff --git a/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake b/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake index 589f702c..15f2a4c5 100644 --- a/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake +++ b/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake @@ -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) diff --git a/apps/mobile/packages/core/lib/core.dart b/apps/mobile/packages/core/lib/core.dart index e8743adc..600ff74f 100644 --- a/apps/mobile/packages/core/lib/core.dart +++ b/apps/mobile/packages/core/lib/core.dart @@ -5,6 +5,8 @@ export 'src/core_module.dart'; export 'src/domain/arguments/usecase_argument.dart'; export 'src/domain/usecases/usecase.dart'; export 'src/utils/date_time_utils.dart'; +export 'src/utils/geo_utils.dart'; +export 'src/utils/time_utils.dart'; export 'src/presentation/widgets/web_mobile_frame.dart'; export 'src/presentation/mixins/bloc_error_handler.dart'; export 'src/presentation/observers/core_bloc_observer.dart'; @@ -33,3 +35,7 @@ export 'src/services/device/gallery/gallery_service.dart'; export 'src/services/device/file/file_picker_service.dart'; export 'src/services/device/file_upload/device_file_upload_service.dart'; export 'src/services/device/audio/audio_recorder_service.dart'; +export 'src/services/device/location/location_service.dart'; +export 'src/services/device/notification/notification_service.dart'; +export 'src/services/device/storage/storage_service.dart'; +export 'src/services/device/background_task/background_task_service.dart'; diff --git a/apps/mobile/packages/core/lib/src/core_module.dart b/apps/mobile/packages/core/lib/src/core_module.dart index 5c71f6aa..1d2c07ea 100644 --- a/apps/mobile/packages/core/lib/src/core_module.dart +++ b/apps/mobile/packages/core/lib/src/core_module.dart @@ -48,5 +48,13 @@ class CoreModule extends Module { apiUploadService: i.get(), ), ); + + // 6. Register Geofence Device Services + i.addLazySingleton(() => const LocationService()); + i.addLazySingleton(() => NotificationService()); + i.addLazySingleton(() => StorageService()); + i.addLazySingleton( + () => const BackgroundTaskService(), + ); } } diff --git a/apps/mobile/packages/core/lib/src/services/device/background_task/background_task_service.dart b/apps/mobile/packages/core/lib/src/services/device/background_task/background_task_service.dart new file mode 100644 index 00000000..d47602f5 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/background_task/background_task_service.dart @@ -0,0 +1,70 @@ +import 'package:krow_domain/krow_domain.dart'; +import 'package:workmanager/workmanager.dart'; + +/// Service that wraps [Workmanager] for scheduling background tasks. +class BackgroundTaskService extends BaseDeviceService { + /// Creates a [BackgroundTaskService] instance. + const BackgroundTaskService(); + + /// Initializes the workmanager with the given [callbackDispatcher]. + Future initialize(Function callbackDispatcher) async { + return action(() async { + await Workmanager().initialize(callbackDispatcher); + }); + } + + /// Registers a periodic background task with the given [frequency]. + Future registerPeriodicTask({ + required String uniqueName, + required String taskName, + Duration frequency = const Duration(minutes: 15), + Map? inputData, + }) async { + return action(() async { + await Workmanager().registerPeriodicTask( + uniqueName, + taskName, + frequency: frequency, + inputData: inputData, + existingWorkPolicy: ExistingPeriodicWorkPolicy.replace, + ); + }); + } + + /// Registers a one-off background task. + Future registerOneOffTask({ + required String uniqueName, + required String taskName, + Map? inputData, + }) async { + return action(() async { + await Workmanager().registerOneOffTask( + uniqueName, + taskName, + inputData: inputData, + ); + }); + } + + /// Cancels a registered task by its [uniqueName]. + Future cancelByUniqueName(String uniqueName) async { + return action(() => Workmanager().cancelByUniqueName(uniqueName)); + } + + /// Cancels all registered background tasks. + Future cancelAll() async { + return action(() => Workmanager().cancelAll()); + } + + /// Registers the task execution callback for the background isolate. + /// + /// Must be called inside the top-level callback dispatcher function. + /// The [callback] receives the task name and optional input data, and + /// must return `true` on success or `false` on failure. + void executeTask( + Future Function(String task, Map? inputData) + callback, + ) { + Workmanager().executeTask(callback); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/device/location/location_service.dart b/apps/mobile/packages/core/lib/src/services/device/location/location_service.dart new file mode 100644 index 00000000..2b583079 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/location/location_service.dart @@ -0,0 +1,110 @@ +import 'dart:async'; + +import 'package:geolocator/geolocator.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Service that wraps [Geolocator] to provide location access. +/// +/// This is the only file in the core package that imports geolocator. +/// All location access across the app should go through this service. +class LocationService extends BaseDeviceService { + /// Creates a [LocationService] instance. + const LocationService(); + + /// Checks the current permission status and requests permission if needed. + Future checkAndRequestPermission() async { + return action(() async { + final bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) return LocationPermissionStatus.serviceDisabled; + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + + return _mapPermission(permission); + }); + } + + /// Requests upgrade to "Always" permission for background location access. + Future requestAlwaysPermission() async { + return action(() async { + // On Android, requesting permission again after whileInUse prompts + // for Always. + final LocationPermission permission = await Geolocator.requestPermission(); + return _mapPermission(permission); + }); + } + + /// Returns the device's current location. + Future getCurrentLocation() async { + return action(() async { + final Position position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + return _toDeviceLocation(position); + }); + } + + /// Emits location updates as a stream, filtered by [distanceFilter] meters. + Stream watchLocation({int distanceFilter = 10}) { + return Geolocator.getPositionStream( + locationSettings: LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: distanceFilter, + ), + ).map(_toDeviceLocation); + } + + /// Whether device location services are currently enabled. + Future isServiceEnabled() async { + return action(() => Geolocator.isLocationServiceEnabled()); + } + + /// Stream that emits when location service status changes. + /// + /// Emits `true` when enabled, `false` when disabled. + Stream get onServiceStatusChanged { + return Geolocator.getServiceStatusStream().map( + (ServiceStatus status) => status == ServiceStatus.enabled, + ); + } + + /// Opens the app settings page for the user to manually grant permissions. + Future openAppSettings() async { + return action(() => Geolocator.openAppSettings()); + } + + /// Opens the device location settings page. + Future openLocationSettings() async { + return action(() => Geolocator.openLocationSettings()); + } + + /// Maps a [LocationPermission] to a [LocationPermissionStatus]. + LocationPermissionStatus _mapPermission(LocationPermission permission) { + switch (permission) { + case LocationPermission.always: + return LocationPermissionStatus.granted; + case LocationPermission.whileInUse: + return LocationPermissionStatus.whileInUse; + case LocationPermission.denied: + return LocationPermissionStatus.denied; + case LocationPermission.deniedForever: + return LocationPermissionStatus.deniedForever; + case LocationPermission.unableToDetermine: + return LocationPermissionStatus.denied; + } + } + + /// Converts a geolocator [Position] to a [DeviceLocation]. + DeviceLocation _toDeviceLocation(Position position) { + return DeviceLocation( + latitude: position.latitude, + longitude: position.longitude, + accuracy: position.accuracy, + timestamp: position.timestamp, + ); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/device/notification/notification_service.dart b/apps/mobile/packages/core/lib/src/services/device/notification/notification_service.dart new file mode 100644 index 00000000..fec59c1b --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/notification/notification_service.dart @@ -0,0 +1,79 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Service that wraps [FlutterLocalNotificationsPlugin] for local notifications. +class NotificationService extends BaseDeviceService { + + /// Creates a [NotificationService] with the given [plugin] instance. + /// + /// If no plugin is provided, a default instance is created. + NotificationService({FlutterLocalNotificationsPlugin? plugin}) + : _plugin = plugin ?? FlutterLocalNotificationsPlugin(); + /// The underlying notification plugin instance. + final FlutterLocalNotificationsPlugin _plugin; + + /// Whether [initialize] has already been called. + bool _initialized = false; + + /// Initializes notification channels and requests permissions. + /// + /// Safe to call multiple times — subsequent calls are no-ops. + Future initialize() async { + if (_initialized) return; + return action(() async { + const AndroidInitializationSettings androidSettings = AndroidInitializationSettings( + '@mipmap/ic_launcher', + ); + const DarwinInitializationSettings iosSettings = DarwinInitializationSettings( + requestAlertPermission: true, + requestBadgePermission: true, + requestSoundPermission: true, + ); + const InitializationSettings settings = InitializationSettings( + android: androidSettings, + iOS: iosSettings, + ); + await _plugin.initialize(settings: settings); + _initialized = true; + }); + } + + /// Ensures the plugin is initialized before use. + Future _ensureInitialized() async { + if (!_initialized) await initialize(); + } + + /// Displays a local notification with the given [title] and [body]. + Future showNotification({ + required String title, + required String body, + int id = 0, + }) async { + await _ensureInitialized(); + return action(() async { + const AndroidNotificationDetails androidDetails = AndroidNotificationDetails( + 'krow_geofence', + 'Geofence Notifications', + channelDescription: 'Notifications for geofence events', + importance: Importance.high, + priority: Priority.high, + ); + const DarwinNotificationDetails iosDetails = DarwinNotificationDetails(); + const NotificationDetails details = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + await _plugin.show(id: id, title: title, body: body, notificationDetails: details); + }); + } + + /// Cancels a specific notification by [id]. + Future cancelNotification(int id) async { + return action(() => _plugin.cancel(id: id)); + } + + /// Cancels all active notifications. + Future cancelAll() async { + return action(() => _plugin.cancelAll()); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/device/storage/storage_service.dart b/apps/mobile/packages/core/lib/src/services/device/storage/storage_service.dart new file mode 100644 index 00000000..5f14f7f5 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/storage/storage_service.dart @@ -0,0 +1,81 @@ +import 'package:krow_domain/krow_domain.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Service that wraps [SharedPreferences] for key-value storage. +class StorageService extends BaseDeviceService { + + /// Creates a [StorageService] instance. + StorageService(); + /// Cached preferences instance. + SharedPreferences? _prefs; + + /// Returns the [SharedPreferences] instance, initializing lazily. + Future get _preferences async { + _prefs ??= await SharedPreferences.getInstance(); + return _prefs!; + } + + /// Retrieves a string value for the given [key]. + Future getString(String key) async { + return action(() async { + final SharedPreferences prefs = await _preferences; + return prefs.getString(key); + }); + } + + /// Stores a string [value] for the given [key]. + Future setString(String key, String value) async { + return action(() async { + final SharedPreferences prefs = await _preferences; + return prefs.setString(key, value); + }); + } + + /// Retrieves a double value for the given [key]. + Future getDouble(String key) async { + return action(() async { + final SharedPreferences prefs = await _preferences; + return prefs.getDouble(key); + }); + } + + /// Stores a double [value] for the given [key]. + Future setDouble(String key, double value) async { + return action(() async { + final SharedPreferences prefs = await _preferences; + return prefs.setDouble(key, value); + }); + } + + /// Retrieves a boolean value for the given [key]. + Future getBool(String key) async { + return action(() async { + final SharedPreferences prefs = await _preferences; + return prefs.getBool(key); + }); + } + + /// Stores a boolean [value] for the given [key]. + Future setBool(String key, bool value) async { + return action(() async { + final SharedPreferences prefs = await _preferences; + return prefs.setBool(key, value); + }); + } + + /// Removes the value for the given [key]. + Future remove(String key) async { + return action(() async { + final SharedPreferences prefs = await _preferences; + return prefs.remove(key); + }); + } + + /// Clears all stored values. + Future clear() async { + return action(() async { + final SharedPreferences prefs = await _preferences; + return prefs.clear(); + }); + } +} diff --git a/apps/mobile/packages/core/lib/src/utils/geo_utils.dart b/apps/mobile/packages/core/lib/src/utils/geo_utils.dart new file mode 100644 index 00000000..0026273d --- /dev/null +++ b/apps/mobile/packages/core/lib/src/utils/geo_utils.dart @@ -0,0 +1,32 @@ +import 'dart:math'; + +/// Calculates the distance in meters between two geographic coordinates +/// using the Haversine formula. +double calculateDistance( + double lat1, + double lng1, + double lat2, + double lng2, +) { + const double earthRadius = 6371000.0; + final double dLat = _toRadians(lat2 - lat1); + final double dLng = _toRadians(lng2 - lng1); + final double a = sin(dLat / 2) * sin(dLat / 2) + + cos(_toRadians(lat1)) * + cos(_toRadians(lat2)) * + sin(dLng / 2) * + sin(dLng / 2); + final double c = 2 * atan2(sqrt(a), sqrt(1 - a)); + return earthRadius * c; +} + +/// Formats a distance in meters to a human-readable string. +String formatDistance(double meters) { + if (meters >= 1000) { + return '${(meters / 1000).toStringAsFixed(1)} km'; + } + return '${meters.round()} m'; +} + +/// Converts degrees to radians. +double _toRadians(double degrees) => degrees * pi / 180; diff --git a/apps/mobile/packages/core/lib/src/utils/time_utils.dart b/apps/mobile/packages/core/lib/src/utils/time_utils.dart new file mode 100644 index 00000000..7340753c --- /dev/null +++ b/apps/mobile/packages/core/lib/src/utils/time_utils.dart @@ -0,0 +1,30 @@ +import 'package:intl/intl.dart'; + +/// Formats a time string (ISO 8601 or HH:mm) into 12-hour format +/// (e.g. "9:00 AM"). +/// +/// Returns the original string unchanged if parsing fails. +String formatTime(String timeStr) { + if (timeStr.isEmpty) return ''; + try { + final DateTime dt = DateTime.parse(timeStr); + return DateFormat('h:mm a').format(dt); + } catch (_) { + try { + final List parts = timeStr.split(':'); + if (parts.length >= 2) { + final DateTime dt = DateTime( + 2022, + 1, + 1, + int.parse(parts[0]), + int.parse(parts[1]), + ); + return DateFormat('h:mm a').format(dt); + } + return timeStr; + } catch (_) { + return timeStr; + } + } +} diff --git a/apps/mobile/packages/core/pubspec.yaml b/apps/mobile/packages/core/pubspec.yaml index 15f91f58..f40200eb 100644 --- a/apps/mobile/packages/core/pubspec.yaml +++ b/apps/mobile/packages/core/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: design_system: path: ../design_system + intl: ^0.20.0 flutter_bloc: ^8.1.0 equatable: ^2.0.8 flutter_modular: ^6.4.1 @@ -27,3 +28,7 @@ dependencies: file_picker: ^8.1.7 record: ^6.2.0 firebase_auth: ^6.1.4 + geolocator: ^14.0.2 + flutter_local_notifications: ^21.0.0 + shared_preferences: ^2.5.4 + workmanager: ^0.9.0+3 diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 7178240d..f4693848 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -856,10 +856,13 @@ "today_shift_badge": "TODAY'S SHIFT", "early_title": "You're early!", "check_in_at": "Check-in available at $time", + "early_checkout_title": "Too early to check out", + "check_out_at": "Check-out available at $time", "shift_completed": "Shift Completed!", "great_work": "Great work today", "no_shifts_today": "No confirmed shifts for today", "accept_shift_cta": "Accept a shift to clock in", + "per_hr": "\\$$amount/hr", "soon": "soon", "checked_in_at_label": "Checked in at", "not_in_range": "You must be within $distance m to clock in.", @@ -926,6 +929,39 @@ "submit": "Submit", "success_title": "Break Logged!", "close": "Close" + }, + "geofence": { + "service_disabled": "Location services are turned off. Enable them to clock in.", + "permission_required": "Location permission is required to clock in.", + "permission_required_desc": "Grant location permission to verify you're at the workplace when clocking in.", + "permission_denied_forever": "Location was permanently denied.", + "permission_denied_forever_desc": "Grant location permission in your device settings to verify you're at the workplace when clocking in.", + "open_settings": "Open Settings", + "grant_permission": "Grant Permission", + "verifying": "Verifying your location...", + "too_far_title": "You're Too Far Away", + "too_far_desc": "You are $distance away. Move within 500m to clock in.", + "verified": "Location Verified", + "not_in_range": "You must be at the workplace to clock in.", + "timeout_title": "Can't Verify Location", + "timeout_desc": "Unable to determine your location. You can still clock in with a note.", + "timeout_note_hint": "Why can't your location be verified?", + "clock_in_greeting_title": "You're Clocked In!", + "clock_in_greeting_body": "Have a great shift. We'll keep track of your location.", + "background_left_title": "You've Left the Workplace", + "background_left_body": "You appear to be more than 500m from your shift location.", + "clock_out_title": "You're Clocked Out!", + "clock_out_body": "Great work today. See you next shift.", + "always_permission_title": "Background Location Needed", + "always_permission_desc": "To verify your location during shifts, please allow location access 'Always'.", + "retry": "Retry", + "clock_in_anyway": "Clock In Anyway", + "override_title": "Justification Required", + "override_desc": "Your location could not be verified. Please explain why you are clocking in without location verification.", + "override_hint": "Enter your justification...", + "override_submit": "Clock In", + "overridden_title": "Location Not Verified", + "overridden_desc": "You are clocking in without location verification. Your justification has been recorded." } }, "availability": { @@ -1416,6 +1452,10 @@ "application_not_found": "Your application couldn't be found.", "no_active_shift": "You don't have an active shift to clock out from." }, + "clock_in": { + "location_verification_required": "Please wait for location verification before clocking in.", + "notes_required_for_timeout": "Please add a note explaining why your location can't be verified." + }, "generic": { "unknown": "Something went wrong. Please try again.", "no_connection": "No internet connection. Please check your network and try again.", diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 5fce4a09..006e3dec 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -851,10 +851,13 @@ "today_shift_badge": "TURNO DE HOY", "early_title": "\u00a1Ha llegado temprano!", "check_in_at": "Entrada disponible a las $time", + "early_checkout_title": "Muy temprano para salir", + "check_out_at": "Salida disponible a las $time", "shift_completed": "\u00a1Turno completado!", "great_work": "Buen trabajo hoy", "no_shifts_today": "No hay turnos confirmados para hoy", "accept_shift_cta": "Acepte un turno para registrar su entrada", + "per_hr": "\\$$amount/hr", "soon": "pronto", "checked_in_at_label": "Entrada registrada a las", "nfc_dialog": { @@ -921,6 +924,39 @@ "submit": "Enviar", "success_title": "\u00a1Descanso registrado!", "close": "Cerrar" + }, + "geofence": { + "service_disabled": "Los servicios de ubicación están desactivados. Actívelos para registrar entrada.", + "permission_required": "Se requiere permiso de ubicación para registrar entrada.", + "permission_required_desc": "Otorgue permiso de ubicación para verificar que está en el lugar de trabajo al registrar entrada.", + "permission_denied_forever": "La ubicación fue denegada permanentemente.", + "permission_denied_forever_desc": "Otorgue permiso de ubicación en la configuración de su dispositivo para verificar que está en el lugar de trabajo al registrar entrada.", + "open_settings": "Abrir Configuración", + "grant_permission": "Otorgar Permiso", + "verifying": "Verificando su ubicación...", + "too_far_title": "Está Demasiado Lejos", + "too_far_desc": "Está a $distance de distancia. Acérquese a 500m para registrar entrada.", + "verified": "Ubicación Verificada", + "not_in_range": "Debe estar en el lugar de trabajo para registrar entrada.", + "timeout_title": "No se Puede Verificar la Ubicación", + "timeout_desc": "No se pudo determinar su ubicación. Puede registrar entrada con una nota.", + "timeout_note_hint": "¿Por qué no se puede verificar su ubicación?", + "clock_in_greeting_title": "¡Entrada Registrada!", + "clock_in_greeting_body": "Buen turno. Seguiremos el registro de su ubicación.", + "background_left_title": "Ha Salido del Lugar de Trabajo", + "background_left_body": "Parece que está a más de 500m de la ubicación de su turno.", + "clock_out_title": "¡Salida Registrada!", + "clock_out_body": "Buen trabajo hoy. Nos vemos en el próximo turno.", + "always_permission_title": "Se Necesita Ubicación en Segundo Plano", + "always_permission_desc": "Para verificar su ubicación durante los turnos, permita el acceso a la ubicación 'Siempre'.", + "retry": "Reintentar", + "clock_in_anyway": "Registrar Entrada", + "override_title": "Justificación Requerida", + "override_desc": "No se pudo verificar su ubicación. Explique por qué registra entrada sin verificación de ubicación.", + "override_hint": "Ingrese su justificación...", + "override_submit": "Registrar Entrada", + "overridden_title": "Ubicación No Verificada", + "overridden_desc": "Está registrando entrada sin verificación de ubicación. Su justificación ha sido registrada." } }, "availability": { @@ -1411,6 +1447,10 @@ "application_not_found": "No se pudo encontrar tu solicitud.", "no_active_shift": "No tienes un turno activo para registrar salida." }, + "clock_in": { + "location_verification_required": "Por favor, espera la verificaci\u00f3n de ubicaci\u00f3n antes de registrar entrada.", + "notes_required_for_timeout": "Por favor, agrega una nota explicando por qu\u00e9 no se puede verificar tu ubicaci\u00f3n." + }, "generic": { "unknown": "Algo sali\u00f3 mal. Por favor, intenta de nuevo.", "no_connection": "Sin conexi\u00f3n a internet. Por favor, verifica tu red e intenta de nuevo.", diff --git a/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart b/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart index 5e7df68d..69e4282d 100644 --- a/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart +++ b/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart @@ -35,6 +35,8 @@ String translateErrorKey(String key) { return _translateProfileError(errorType); case 'shift': return _translateShiftError(errorType); + case 'clock_in': + return _translateClockInError(errorType); case 'generic': return _translateGenericError(errorType); default: @@ -127,6 +129,18 @@ String _translateShiftError(String errorType) { } } +/// Translates clock-in error keys to localized strings. +String _translateClockInError(String errorType) { + switch (errorType) { + case 'location_verification_required': + return t.errors.clock_in.location_verification_required; + case 'notes_required_for_timeout': + return t.errors.clock_in.notes_required_for_timeout; + default: + return t.errors.generic.unknown; + } +} + String _translateGenericError(String errorType) { switch (errorType) { case 'unknown': diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart index cb760a6f..4f6e1ed9 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart @@ -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 diff --git a/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart b/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart index 430d163d..478f0c91 100644 --- a/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart +++ b/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart @@ -15,18 +15,32 @@ class UiNoticeBanner extends StatelessWidget { this.backgroundColor, this.borderRadius, this.padding, + this.iconColor, + this.titleColor, + this.descriptionColor, + this.action, + this.leading, }); /// The icon to display on the left side. - /// Defaults to null. The icon will be rendered with primary color and 24pt size. + /// Ignored when [leading] is provided. final IconData? icon; + /// Custom color for the icon. Defaults to [UiColors.primary]. + final Color? iconColor; + /// The title text to display. final String title; + /// Custom color for the title text. Defaults to primary text color. + final Color? titleColor; + /// Optional description text to display below the title. final String? description; + /// Custom color for the description text. Defaults to secondary text color. + final Color? descriptionColor; + /// The background color of the banner. /// Defaults to [UiColors.primary] with 8% opacity. final Color? backgroundColor; @@ -39,6 +53,12 @@ class UiNoticeBanner extends StatelessWidget { /// Defaults to [UiConstants.space4] on all sides. final EdgeInsetsGeometry? padding; + /// Optional action widget displayed on the right side of the banner. + final Widget? action; + + /// Optional custom leading widget that replaces the icon when provided. + final Widget? leading; + @override Widget build(BuildContext context) { return Container( @@ -50,8 +70,11 @@ class UiNoticeBanner extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (icon != null) ...[ - Icon(icon, color: UiColors.primary, size: 24), + if (leading != null) ...[ + leading!, + const SizedBox(width: UiConstants.space3), + ] else if (icon != null) ...[ + Icon(icon, color: iconColor ?? UiColors.primary, size: 24), const SizedBox(width: UiConstants.space3), ], Expanded( @@ -60,15 +83,21 @@ class UiNoticeBanner extends StatelessWidget { children: [ Text( title, - style: UiTypography.body2m.textPrimary, + style: UiTypography.body2b.copyWith(color: titleColor), ), if (description != null) ...[ const SizedBox(height: 2), Text( description!, - style: UiTypography.body2r.textSecondary, + style: UiTypography.body3r.copyWith( + color: descriptionColor, + ), ), ], + if (action != null) ...[ + const SizedBox(height: UiConstants.space2), + action!, + ], ], ), ), diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index 87b22493..c98147f3 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -14,6 +14,10 @@ export 'src/core/services/api_services/file_visibility.dart'; // Device export 'src/core/services/device/base_device_service.dart'; +export 'src/core/services/device/location_permission_status.dart'; + +// Models +export 'src/core/models/device_location.dart'; // Users & Membership export 'src/entities/users/user.dart'; diff --git a/apps/mobile/packages/domain/lib/src/core/models/device_location.dart b/apps/mobile/packages/domain/lib/src/core/models/device_location.dart new file mode 100644 index 00000000..0f83b3b7 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/core/models/device_location.dart @@ -0,0 +1,27 @@ +import 'package:equatable/equatable.dart'; + +/// Represents a geographic location obtained from the device. +class DeviceLocation extends Equatable { + /// Latitude in degrees. + final double latitude; + + /// Longitude in degrees. + final double longitude; + + /// Estimated horizontal accuracy in meters. + final double accuracy; + + /// Time when this location was determined. + final DateTime timestamp; + + /// Creates a [DeviceLocation] instance. + const DeviceLocation({ + required this.latitude, + required this.longitude, + required this.accuracy, + required this.timestamp, + }); + + @override + List get props => [latitude, longitude, accuracy, timestamp]; +} diff --git a/apps/mobile/packages/domain/lib/src/core/services/device/location_permission_status.dart b/apps/mobile/packages/domain/lib/src/core/services/device/location_permission_status.dart new file mode 100644 index 00000000..e9b5ff97 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/core/services/device/location_permission_status.dart @@ -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, +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart index ee196446..487c55b7 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart @@ -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); diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift index 3eb92bc4..36b8a0c0 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,9 @@ import file_selector_macos import firebase_app_check import firebase_auth import firebase_core +import flutter_local_notifications +import geolocator_apple +import package_info_plus import record_macos import shared_preferences_foundation @@ -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")) } diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.cc b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.cc index ec331e03..2406d471 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include 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")); } diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugins.cmake b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugins.cmake index 0125068a..0c9f9e28 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugins.cmake +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugins.cmake @@ -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) diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart index ea0e990f..c2509429 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart @@ -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 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(); }); diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart new file mode 100644 index 00000000..d22ea458 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart @@ -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? 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 startBackgroundTracking({ + required double targetLat, + required double targetLng, + required String shiftId, + required String leftGeofenceTitle, + required String leftGeofenceBody, + }) async { + await Future.wait(>[ + _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: { + '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 stopBackgroundTracking() async { + await _backgroundTaskService.cancelByUniqueName(taskUniqueName); + + await Future.wait(>[ + _storageService.remove(_keyTargetLat), + _storageService.remove(_keyTargetLng), + _storageService.remove(_keyShiftId), + _storageService.setBool(_keyTrackingActive, false), + ]); + } + + /// Whether background tracking is currently active. + Future get isTrackingActive async { + final bool? active = await _storageService.getBool(_keyTrackingActive); + return active ?? false; + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/clock_in_notification_service.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/clock_in_notification_service.dart new file mode 100644 index 00000000..17b5f0a6 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/clock_in_notification_service.dart @@ -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 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 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 showLeftGeofenceNotification({ + required String title, + required String body, + }) async { + await _notificationService.showNotification( + title: title, + body: body, + id: leftGeofenceNotificationId, + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/geofence_service_impl.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/geofence_service_impl.dart new file mode 100644 index 00000000..cc4d00d6 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/geofence_service_impl.dart @@ -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 ensurePermission() { + return _locationService.checkAndRequestPermission(); + } + + @override + Future requestAlwaysPermission() { + return _locationService.requestAlwaysPermission(); + } + + @override + Stream 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 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 watchServiceStatus() { + return _locationService.onServiceStatusChanged; + } + + @override + Future openAppSettings() async { + await _locationService.openAppSettings(); + } + + @override + Future 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, + ); + } + +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/models/geofence_result.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/models/geofence_result.dart new file mode 100644 index 00000000..95043929 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/models/geofence_result.dart @@ -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 get props => [ + distanceMeters, + isWithinRadius, + estimatedEtaMinutes, + location, + ]; +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/services/geofence_service_interface.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/services/geofence_service_interface.dart new file mode 100644 index 00000000..099ade09 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/services/geofence_service_interface.dart @@ -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 ensurePermission(); + + /// Requests upgrade to "Always" permission for background access. + Future requestAlwaysPermission(); + + /// Emits geofence results as the device moves relative to a target. + Stream watchGeofence({ + required double targetLat, + required double targetLng, + double radiusMeters = 500, + }); + + /// Checks geofence once with a timeout. Returns null if GPS times out. + Future 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 watchServiceStatus(); + + /// Opens the app settings page. + Future openAppSettings(); + + /// Opens the device location settings page. + Future openLocationSettings(); +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validation_context.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validation_context.dart new file mode 100644 index 00000000..6a071e58 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validation_context.dart @@ -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 get props => [ + isCheckingIn, + shiftStartTime, + shiftEndTime, + hasCoordinates, + isLocationVerified, + isLocationTimedOut, + isGeofenceOverridden, + overrideNotes, + ]; +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validation_result.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validation_result.dart new file mode 100644 index 00000000..59a03ac2 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validation_result.dart @@ -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 get props => [isValid, errorKey]; +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/clock_in_validator.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/clock_in_validator.dart new file mode 100644 index 00000000..62ccdc54 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/clock_in_validator.dart @@ -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); +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/composite_clock_in_validator.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/composite_clock_in_validator.dart new file mode 100644 index 00000000..d6cd0173 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/composite_clock_in_validator.dart @@ -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 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(); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/geofence_validator.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/geofence_validator.dart new file mode 100644 index 00000000..cf2d8704 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/geofence_validator.dart @@ -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(); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/override_notes_validator.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/override_notes_validator.dart new file mode 100644 index 00000000..22eef4c0 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/override_notes_validator.dart @@ -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(); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/time_window_validator.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/time_window_validator.dart new file mode 100644 index 00000000..a38edd62 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/time_window_validator.dart @@ -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); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart new file mode 100644 index 00000000..3a7e0a0e --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart @@ -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 + with BlocErrorHandler { + /// 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(_onLoaded); + on(_onShiftSelected); + on(_onDateSelected); + on(_onCheckIn); + on(_onCheckOut); + on(_onModeChanged); + on(_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 _onLoaded( + ClockInPageLoaded event, + Emitter emit, + ) async { + emit(state.copyWith(status: ClockInStatus.loading)); + await handleError( + emit: emit.call, + action: () async { + final List 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 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 _onDateSelected( + DateSelected event, + Emitter emit, + ) async { + emit(state.copyWith(selectedDate: event.date)); + await _onLoaded(ClockInPageLoaded(), emit); + } + + /// Updates the check-in interaction mode. + void _onModeChanged( + CheckInModeChanged event, + Emitter 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 _onCheckIn( + CheckInRequested event, + Emitter 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 _onCheckOut( + CheckOutRequested event, + Emitter 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 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 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; +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart new file mode 100644 index 00000000..36652f26 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart @@ -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 get props => []; +} + +/// 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 get props => [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 get props => [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 get props => [ + 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 get props => [ + 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 get props => [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(); +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_state.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_state.dart new file mode 100644 index 00000000..ddda84ed --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_state.dart @@ -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 [], + 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 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? 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 get props => [ + status, + todayShifts, + selectedShift, + attendance, + selectedDate, + checkInMode, + errorMessage, + isCheckInAllowed, + isCheckOutAllowed, + checkInAvailabilityTime, + checkOutAvailabilityTime, + ]; +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart deleted file mode 100644 index 5f5c3650..00000000 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart +++ /dev/null @@ -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 - with BlocErrorHandler { - 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(_onLoaded); - on(_onShiftSelected); - on(_onDateSelected); - on(_onCheckIn); - on(_onCheckOut); - on(_onModeChanged); - on(_onRequestLocationPermission); - on(_onCommuteModeToggled); - on(_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 _onLoaded( - ClockInPageLoaded event, - Emitter emit, - ) async { - emit(state.copyWith(status: ClockInStatus.loading)); - await handleError( - emit: emit.call, - action: () async { - final List 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 _onRequestLocationPermission( - RequestLocationPermission event, - Emitter 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 _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 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 emit, - ) { - emit(state.copyWith(isCommuteModeOn: event.isEnabled)); - if (event.isEnabled) { - add(RequestLocationPermission()); - } - } - - void _onShiftSelected( - ShiftSelected event, - Emitter emit, - ) { - emit(state.copyWith(selectedShift: event.shift)); - if (!state.attendance.isCheckedIn) { - _startLocationUpdates(); - } - } - - void _onDateSelected( - DateSelected event, - Emitter emit, - ) { - emit(state.copyWith(selectedDate: event.date)); - } - - void _onModeChanged( - CheckInModeChanged event, - Emitter emit, - ) { - emit(state.copyWith(checkInMode: event.mode)); - } - - Future _onCheckIn( - CheckInRequested event, - Emitter 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 _onCheckOut( - CheckOutRequested event, - Emitter 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, - ), - ); - } -} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart deleted file mode 100644 index 85dd1614..00000000 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart +++ /dev/null @@ -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 get props => []; -} - -class ClockInPageLoaded extends ClockInEvent {} - -class ShiftSelected extends ClockInEvent { - const ShiftSelected(this.shift); - final Shift shift; - - @override - List get props => [shift]; -} - -class DateSelected extends ClockInEvent { - - const DateSelected(this.date); - final DateTime date; - - @override - List get props => [date]; -} - -class CheckInRequested extends ClockInEvent { - - const CheckInRequested({required this.shiftId, this.notes}); - final String shiftId; - final String? notes; - - @override - List get props => [shiftId, notes]; -} - -class CheckOutRequested extends ClockInEvent { - - const CheckOutRequested({this.notes, this.breakTimeMinutes}); - final String? notes; - final int? breakTimeMinutes; - - @override - List get props => [notes, breakTimeMinutes]; -} - -class CheckInModeChanged extends ClockInEvent { - - const CheckInModeChanged(this.mode); - final String mode; - - @override - List get props => [mode]; -} - -class CommuteModeToggled extends ClockInEvent { - - const CommuteModeToggled(this.isEnabled); - final bool isEnabled; - - @override - List get props => [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 get props => [position, distance, isVerified]; -} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_state.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_state.dart deleted file mode 100644 index 2474b519..00000000 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_state.dart +++ /dev/null @@ -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 [], - 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 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? 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 get props => [ - status, - todayShifts, - selectedShift, - attendance, - selectedDate, - checkInMode, - errorMessage, - currentLocation, - distanceFromVenue, - isLocationVerified, - isCommuteModeOn, - hasLocationConsent, - etaMinutes, - ]; -} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart new file mode 100644 index 00000000..d9c6a260 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart @@ -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 + with + BlocErrorHandler, + SafeBloc { + + /// 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(_onStarted); + on(_onResultUpdated); + on(_onTimeout); + on(_onServiceStatusChanged); + on(_onRetry); + on(_onBackgroundTrackingStarted); + on(_onBackgroundTrackingStopped); + on(_onOverrideApproved); + on(_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? _geofenceSubscription; + + /// Active subscription to the location service status stream. + StreamSubscription? _serviceStatusSubscription; + + /// Handles the [GeofenceStarted] event by requesting permission, performing + /// an initial geofence check, and starting the foreground location stream. + Future _onStarted( + GeofenceStarted event, + Emitter 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 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 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 _onServiceStatusChanged( + GeofenceServiceStatusChanged event, + Emitter 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 _onRetry( + GeofenceRetryRequested event, + Emitter 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 _onBackgroundTrackingStarted( + BackgroundTrackingStarted event, + Emitter 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 _onBackgroundTrackingStopped( + BackgroundTrackingStopped event, + Emitter 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 emit, + ) { + emit(state.copyWith( + isGeofenceOverridden: true, + overrideNotes: event.notes, + )); + } + + /// Handles the [GeofenceStopped] event by cancelling all subscriptions + /// and resetting the state. + Future _onStopped( + GeofenceStopped event, + Emitter emit, + ) async { + await _geofenceSubscription?.cancel(); + _geofenceSubscription = null; + await _serviceStatusSubscription?.cancel(); + _serviceStatusSubscription = null; + emit(const GeofenceState.initial()); + } + + @override + Future close() { + _geofenceSubscription?.cancel(); + _serviceStatusSubscription?.cancel(); + return super.close(); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart new file mode 100644 index 00000000..980d5c5d --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart @@ -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 get props => []; +} + +/// 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 get props => [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 get props => [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 get props => [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 get props => [ + 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 get props => [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 get props => [notes]; +} + +/// Stops all geofence monitoring (foreground and background). +class GeofenceStopped extends GeofenceEvent { + /// Creates a [GeofenceStopped] event. + const GeofenceStopped(); +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_state.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_state.dart new file mode 100644 index 00000000..d73a4b2e --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_state.dart @@ -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 get props => [ + permissionStatus, + isLocationServiceEnabled, + currentLocation, + distanceFromTarget, + isLocationVerified, + isLocationTimedOut, + isVerifying, + isBackgroundTrackingActive, + isGeofenceOverridden, + overrideNotes, + targetLat, + targetLng, + ]; +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/blocs/clock_in_cubit.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/blocs/clock_in_cubit.dart deleted file mode 100644 index 01067185..00000000 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/blocs/clock_in_cubit.dart +++ /dev/null @@ -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 get props => [ - isLoading, - isLocationVerified, - error, - currentLocation, - distanceFromVenue, - isClockedIn, - clockInTime, - ]; -} - -// --- Cubit --- -class ClockInCubit extends Cubit { // 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 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 _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 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 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, - )); - } -} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart index 76636878..c6b1ffa6 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart @@ -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 createState() => _ClockInPageState(); -} - -class _ClockInPageState extends State { - late final ClockInBloc _bloc; - - @override - void initState() { - super.initState(); - _bloc = Modular.get(); - } - @override Widget build(BuildContext context) { final TranslationsStaffClockInEn i18n = Translations.of( context, ).staff.clock_in; - return BlocProvider.value( - value: _bloc, - child: BlocConsumer( - 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.value( + value: Modular.get(), + ), + BlocProvider( + create: (BuildContext _) { + final ClockInBloc bloc = Modular.get(); + bloc.add(ClockInPageLoaded()); + return bloc; + }, + ), + ], + child: BlocListener( + 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( + 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 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: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space5, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // // 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: [ - 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: [ - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - 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: [ - 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) ...[ - 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: [ - 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 ...[ - // Attire Photo Section - // if (!isCheckedIn) ...[ - // 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: [ - // 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: [ - // 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)) ...[ - // 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) ...[ - // 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: [ - 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 ...[ - // No Shift State - Container( - width: double.infinity, - padding: const EdgeInsets.all(UiConstants.space6), - decoration: BoxDecoration( - color: UiColors.bgSecondary, - borderRadius: UiConstants.radiusLg, - ), - child: Column( - children: [ - 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) ...[ - 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: [ - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - 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( - color: UiColors.black.withValues(alpha: 0.05), - blurRadius: 2, - offset: const Offset(0, 1), - ), - ] - : [], - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - 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 _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: [ - 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) ...[ - 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 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; - } - } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/check_in_interaction.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/check_in_interaction.dart new file mode 100644 index 00000000..dd0f5bdc --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/check_in_interaction.dart @@ -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, + }); +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/nfc_check_in_interaction.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/nfc_check_in_interaction.dart new file mode 100644 index 00000000..8dc3297d --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/nfc_check_in_interaction.dart @@ -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( + color: baseColor.withValues(alpha: 0.4), + blurRadius: 25, + offset: const Offset(0, 10), + spreadRadius: -5, + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + 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 _handleTap(BuildContext context) async { + if (isLoading || isDisabled) return; + + final bool scanned = await showNfcScanDialog(context); + if (scanned && context.mounted) { + if (isCheckedIn) { + onCheckOut(); + } else { + onCheckIn(); + } + } + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/swipe_check_in_interaction.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/swipe_check_in_interaction.dart new file mode 100644 index 00000000..56a6a1ee --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/swipe_check_in_interaction.dart @@ -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, + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/check_in_mode_tab.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/check_in_mode_tab.dart new file mode 100644 index 00000000..5eedf057 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/check_in_mode_tab.dart @@ -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().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( + color: UiColors.black.withValues(alpha: 0.05), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ] + : [], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + 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, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/checked_in_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/checked_in_banner.dart new file mode 100644 index 00000000..eb254b01 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/checked_in_banner.dart @@ -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: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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, + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart new file mode 100644 index 00000000..5a5ec04d --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart @@ -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 _interactions = + { + '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: [ + const GeofenceStatusBanner(), + const SizedBox(height: UiConstants.space3), + EarlyCheckInBanner( + availabilityTime: checkInAvailabilityTime ?? soonLabel, + ), + ], + ); + } + + if (isCheckedIn && !isCheckOutAllowed) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const GeofenceStatusBanner(), + const SizedBox(height: UiConstants.space3), + EarlyCheckOutBanner( + availabilityTime: checkOutAvailabilityTime ?? soonLabel, + ), + ], + ); + } + + return BlocBuilder( + 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: [ + 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().state; + final TranslationsStaffClockInGeofenceEn geofenceI18n = Translations.of( + context, + ).staff.clock_in.geofence; + + ReadContext(context).read().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( + context: context, + builder: (BuildContext dialogContext) => LunchBreakDialog( + onComplete: (int breakTimeMinutes) { + Modular.to.popSafe(); + ReadContext(context).read().add( + CheckOutRequested( + breakTimeMinutes: breakTimeMinutes, + clockOutTitle: geofenceI18n.clock_out_title, + clockOutBody: geofenceI18n.clock_out_body, + ), + ); + }, + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart new file mode 100644 index 00000000..fc67a5b4 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart @@ -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 createState() => _ClockInBodyState(); +} + +class _ClockInBodyState extends State { + @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().state.selectedShift; + _syncGeofence(context, selectedShift); + }); + } + + @override + Widget build(BuildContext context) { + final TranslationsStaffClockInEn i18n = Translations.of( + context, + ).staff.clock_in; + + return BlocListener( + 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( + builder: (BuildContext context, ClockInState state) { + final List 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: [ + // date selector + DateSelector( + selectedDate: state.selectedDate, + onSelect: (DateTime date) => + ReadContext(context).read().add(DateSelected(date)), + shiftDates: [ + 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() + .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) ...[ + 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(); + + if (shift != null && shift.latitude != null && shift.longitude != null) { + geofenceBloc.add( + GeofenceStarted( + targetLat: shift.latitude!, + targetLng: shift.longitude!, + ), + ); + } else { + geofenceBloc.add(const GeofenceStopped()); + } + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/date_selector.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/date_selector.dart index 2d849477..c91be1a4 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/date_selector.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/date_selector.dart @@ -16,7 +16,7 @@ class DateSelector extends StatelessWidget { @override Widget build(BuildContext context) { final DateTime today = DateTime.now(); - final List dates = List.generate(7, (int index) { + final List dates = List.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( - color: UiColors.primary.withValues(alpha: 0.3), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ] - : [], ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - 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: [ + 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); } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/early_check_in_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/early_check_in_banner.dart new file mode 100644 index 00000000..18f36835 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/early_check_in_banner.dart @@ -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: [ + 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, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/early_check_out_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/early_check_out_banner.dart new file mode 100644 index 00000000..eda5272a --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/early_check_out_banner.dart @@ -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: [ + 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, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/banner_action_button.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/banner_action_button.dart new file mode 100644 index 00000000..74f74e90 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/banner_action_button.dart @@ -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, + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/banner_actions_row.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/banner_actions_row.dart new file mode 100644 index 00000000..0a76c97f --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/banner_actions_row.dart @@ -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 children; + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: UiConstants.space2, + runSpacing: UiConstants.space2, + children: children, + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart new file mode 100644 index 00000000..56072000 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart @@ -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(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(UiConstants.space4), + ), + ), + builder: (_) => BlocProvider.value( + value: bloc, + child: const GeofenceOverrideModal(), + ), + ); + } + + @override + State createState() => _GeofenceOverrideModalState(); +} + +class _GeofenceOverrideModalState extends State { + 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: [ + 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().add( + GeofenceOverrideApproved(notes: justification), + ); + + Navigator.of(context).pop(); + //Modular.to.popSafe(); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_status_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_status_banner.dart new file mode 100644 index 00000000..115bb840 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_status_banner.dart @@ -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( + 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(); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/overridden_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/overridden_banner.dart new file mode 100644 index 00000000..047192c6 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/overridden_banner.dart @@ -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, + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/permission_denied_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/permission_denied_banner.dart new file mode 100644 index 00000000..898031f7 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/permission_denied_banner.dart @@ -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: [ + BannerActionButton( + label: i18n.grant_permission, + onPressed: () { + if (state.targetLat != null && state.targetLng != null) { + ReadContext(context).read().add( + GeofenceStarted( + targetLat: state.targetLat!, + targetLng: state.targetLng!, + ), + ); + } + }, + ), + BannerActionButton( + label: i18n.clock_in_anyway, + onPressed: () => GeofenceOverrideModal.show(context), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/permission_denied_forever_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/permission_denied_forever_banner.dart new file mode 100644 index 00000000..7cc4a157 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/permission_denied_forever_banner.dart @@ -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: [ + BannerActionButton( + label: i18n.clock_in_anyway, + color: UiColors.textError, + onPressed: () => GeofenceOverrideModal.show(context), + ), + BannerActionButton( + label: i18n.open_settings, + onPressed: () => + Modular.get().openAppSettings(), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/service_disabled_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/service_disabled_banner.dart new file mode 100644 index 00000000..687de2ad --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/service_disabled_banner.dart @@ -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: [ + BannerActionButton( + label: i18n.open_settings, + onPressed: () => + Modular.get().openLocationSettings(), + ), + BannerActionButton( + label: i18n.clock_in_anyway, + onPressed: () => GeofenceOverrideModal.show(context), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/timeout_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/timeout_banner.dart new file mode 100644 index 00000000..7f7edaab --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/timeout_banner.dart @@ -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: [ + BannerActionButton( + label: i18n.retry, + color: UiColors.textWarning, + onPressed: () { + ReadContext(context).read().add( + const GeofenceRetryRequested(), + ); + }, + ), + BannerActionButton( + label: i18n.clock_in_anyway, + color: UiColors.textWarning, + onPressed: () => GeofenceOverrideModal.show(context), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/too_far_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/too_far_banner.dart new file mode 100644 index 00000000..b6c5c56a --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/too_far_banner.dart @@ -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), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/verified_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/verified_banner.dart new file mode 100644 index 00000000..08653cdc --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/verified_banner.dart @@ -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, + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/verifying_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/verifying_banner.dart new file mode 100644 index 00000000..537d388e --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/verifying_banner.dart @@ -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, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart index 47ceb80d..7aac190d 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart @@ -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 onComplete; @override State createState() => _LunchBreakDialogState(); @@ -25,6 +32,36 @@ class _LunchBreakDialogState extends State { final List _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 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 _generateTimeOptions() { final List options = []; for (int h = 0; h < 24; h++) { @@ -258,9 +295,11 @@ class _LunchBreakDialogState extends State { 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 { ), 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), diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/nfc_scan_dialog.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/nfc_scan_dialog.dart new file mode 100644 index 00000000..3ba0e995 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/nfc_scan_dialog.dart @@ -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 showNfcScanDialog(BuildContext context) async { + final TranslationsStaffClockInEn i18n = Translations.of( + context, + ).staff.clock_in; + bool scanned = false; + + await showDialog( + 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.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: [ + 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) ...[ + 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, + ), + ), + ), + ), + ], + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/no_shifts_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/no_shifts_banner.dart new file mode 100644 index 00000000..d6b26227 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/no_shifts_banner.dart @@ -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: [ + 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, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart new file mode 100644 index 00000000..5224e922 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart @@ -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: [ + 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: [ + 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: [ + 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), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card_list.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card_list.dart new file mode 100644 index 00000000..8dfa7fb7 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card_list.dart @@ -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 shifts; + + /// The ID of the currently selected shift, if any. + final String? selectedShiftId; + + /// Called when the user taps a shift card. + final ValueChanged onShiftSelected; + + @override + Widget build(BuildContext context) { + return Column( + children: shifts + .map( + (Shift shift) => ShiftCard( + shift: shift, + isSelected: shift.id == selectedShiftId, + onTap: () => onShiftSelected(shift), + ), + ) + .toList(), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_completed_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_completed_banner.dart new file mode 100644 index 00000000..add07b7a --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_completed_banner.dart @@ -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: [ + 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), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart index 25113d73..4cac15dc 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart @@ -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 createState() => _SwipeToCheckInState(); } @@ -33,12 +51,23 @@ class _SwipeToCheckInState extends State @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 _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 @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( - color: baseColor.withValues(alpha: 0.4), - blurRadius: 25, - offset: const Offset(0, 10), - spreadRadius: -5, - ), - ], - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - 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 decoration: BoxDecoration( color: currentColor, borderRadius: UiConstants.radiusLg, - boxShadow: [ - BoxShadow( - color: UiColors.black.withValues(alpha: 0.1), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], ), child: Stack( children: [ diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart index ffd19c01..32945ba3 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart @@ -3,28 +3,91 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'data/repositories_impl/clock_in_repository_impl.dart'; +import 'data/services/background_geofence_service.dart'; +import 'data/services/clock_in_notification_service.dart'; +import 'data/services/geofence_service_impl.dart'; import 'domain/repositories/clock_in_repository_interface.dart'; +import 'domain/services/geofence_service_interface.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 'presentation/bloc/clock_in_bloc.dart'; +import 'domain/validators/validators/clock_in_validator.dart'; +import 'domain/validators/validators/composite_clock_in_validator.dart'; +import 'domain/validators/validators/geofence_validator.dart'; +import 'domain/validators/validators/override_notes_validator.dart'; +import 'domain/validators/validators/time_window_validator.dart'; +import 'presentation/bloc/clock_in/clock_in_bloc.dart'; +import 'presentation/bloc/geofence/geofence_bloc.dart'; import 'presentation/pages/clock_in_page.dart'; +/// Module for the staff clock-in feature. +/// +/// Registers repositories, use cases, validators, geofence services, and BLoCs. class StaffClockInModule extends Module { + @override + List get imports => [CoreModule()]; + @override void binds(Injector i) { // Repositories i.add(ClockInRepositoryImpl.new); + // Geofence Services (resolve core singletons from DI) + i.add( + () => GeofenceServiceImpl( + locationService: i.get(), + ), + ); + i.add( + () => BackgroundGeofenceService( + backgroundTaskService: i.get(), + storageService: i.get(), + ), + ); + + // Notification Service (clock-in / clock-out / geofence notifications) + i.add( + () => ClockInNotificationService( + notificationService: i.get(), + ), + ); + // Use Cases i.add(GetTodaysShiftUseCase.new); i.add(GetAttendanceStatusUseCase.new); i.add(ClockInUseCase.new); i.add(ClockOutUseCase.new); - // BLoC - i.add(ClockInBloc.new); + // Validators + i.addLazySingleton( + () => const CompositeClockInValidator([ + GeofenceValidator(), + TimeWindowValidator(), + OverrideNotesValidator(), + ]), + ); + + // BLoCs + // GeofenceBloc is a lazy singleton so that ClockInBloc and the widget tree + // share the same instance within a navigation scope. + i.addLazySingleton( + () => GeofenceBloc( + geofenceService: i.get(), + backgroundGeofenceService: i.get(), + notificationService: i.get(), + ), + ); + i.add( + () => ClockInBloc( + getTodaysShift: i.get(), + getAttendanceStatus: i.get(), + clockIn: i.get(), + clockOut: i.get(), + geofenceBloc: i.get(), + validator: i.get(), + ), + ); } @override diff --git a/apps/mobile/packages/features/staff/clock_in/lib/staff_clock_in.dart b/apps/mobile/packages/features/staff/clock_in/lib/staff_clock_in.dart index 60e7610d..016f1414 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/staff_clock_in.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/staff_clock_in.dart @@ -1,4 +1,6 @@ library; +export 'src/data/services/background_geofence_service.dart' + show backgroundGeofenceDispatcher; export 'src/staff_clock_in_module.dart'; export 'src/presentation/pages/clock_in_page.dart'; diff --git a/apps/mobile/packages/features/staff/clock_in/pubspec.yaml b/apps/mobile/packages/features/staff/clock_in/pubspec.yaml index 7ccaafe9..9b53e8e6 100644 --- a/apps/mobile/packages/features/staff/clock_in/pubspec.yaml +++ b/apps/mobile/packages/features/staff/clock_in/pubspec.yaml @@ -28,6 +28,4 @@ dependencies: krow_core: path: ../../../core firebase_data_connect: ^0.2.2+2 - geolocator: ^10.1.0 - permission_handler: ^11.0.1 firebase_auth: ^6.1.4 diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart index 0a56ae04..15b28f85 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart @@ -155,7 +155,9 @@ class _ShiftDetailsPageState extends State { ), ), ShiftDetailsHeader(shift: displayShift), + const Divider(height: 1, thickness: 0.5), + ShiftStatsRow( estimatedTotal: estimatedTotal, hourlyRate: displayShift.hourlyRate, @@ -164,7 +166,9 @@ class _ShiftDetailsPageState extends State { hourlyRateLabel: i18n.hourly_rate, hoursLabel: i18n.hours, ), + const Divider(height: 1, thickness: 0.5), + ShiftDateTimeSection( date: displayShift.date, endDate: displayShift.endDate, diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart index ccfeae3b..4ad8cba7 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart @@ -43,13 +43,6 @@ class ShiftDetailsBottomBar extends StatelessWidget { decoration: BoxDecoration( color: UiColors.white, border: Border(top: BorderSide(color: UiColors.border)), - boxShadow: [ - BoxShadow( - color: UiColors.popupShadow.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, -4), - ), - ], ), child: _buildButtons(status, i18n, context), ); diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart index 4d6458f7..ea594220 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart @@ -8,93 +8,67 @@ class ShiftDetailsHeader extends StatelessWidget { final Shift shift; /// Creates a [ShiftDetailsHeader]. - const ShiftDetailsHeader({ - super.key, - required this.shift, - }); + const ShiftDetailsHeader({super.key, required this.shift}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(UiConstants.space5), - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: UiConstants.space4, - children: [ - Container( - width: 114, - decoration: BoxDecoration( - color: UiColors.primary.withAlpha(20), - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: UiConstants.space4, + children: [ + // Icon + role name + client name + Row( + crossAxisAlignment: CrossAxisAlignment.center, + spacing: UiConstants.space4, + children: [ + Container( + width: 68, + height: 68, + decoration: BoxDecoration( + color: UiColors.primary.withAlpha(20), + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.primary, width: 0.5), ), - border: Border.all(color: UiColors.primary), - ), - child: const Center( - child: Icon( - UiIcons.briefcase, - color: UiColors.primary, - size: 24, + child: const Center( + child: Icon( + UiIcons.briefcase, + color: UiColors.primary, + size: 20, + ), ), ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - spacing: UiConstants.space3, - children: [ - Text( - shift.title, - style: UiTypography.headline1b.textPrimary, - ), - Column( - spacing: UiConstants.space1, - children: [ - // Client name - Row( - spacing: UiConstants.space1, - children: [ - const Icon( - UiIcons.building, - size: 16, - color: UiColors.textSecondary, - ), - Expanded( - child: Text( - shift.clientName, - style: UiTypography.body1m.textSecondary, - ), - ), - ], - ), - - // Location address (if available) - Row( - spacing: UiConstants.space1, - children: [ - const Icon( - UiIcons.mapPin, - size: 16, - color: UiColors.textSecondary, - ), - Expanded( - child: Text( - shift.locationAddress, - style: UiTypography.body2r.textSecondary, - ), - ), - ], - ), - ], - ), - - ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(shift.title, style: UiTypography.headline1b.textPrimary), + Text(shift.clientName, style: UiTypography.body1m.textSecondary), + ], + ), ), - ), - ], - ), + ], + ), + + // Location address + Row( + spacing: UiConstants.space1, + children: [ + const Icon( + UiIcons.mapPin, + size: 16, + color: UiColors.textSecondary, + ), + Expanded( + child: Text( + shift.locationAddress, + style: UiTypography.body2r.textSecondary, + ), + ), + ], + ), + ], ), ); } diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index c08e4dd6..e28d4536 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -289,6 +289,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" diff_match_patch: dependency: transitive description: @@ -510,6 +518,38 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: transitive + description: + name: flutter_local_notifications + sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1" + url: "https://pub.dev" + source: hosted + version: "21.0.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd + url: "https://pub.dev" + source: hosted + version: "8.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307 + url: "https://pub.dev" + source: hosted + version: "11.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c" + url: "https://pub.dev" + source: hosted + version: "3.0.0" flutter_localizations: dependency: transitive description: flutter @@ -557,22 +597,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" geolocator: dependency: transitive description: name: geolocator - sha256: f4efb8d3c4cdcad2e226af9661eb1a0dd38c71a9494b22526f9da80ab79520e5 + sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516" url: "https://pub.dev" source: hosted - version: "10.1.1" + version: "14.0.2" geolocator_android: dependency: transitive description: name: geolocator_android - sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + sha256: "179c3cb66dfa674fc9ccbf2be872a02658724d1c067634e2c427cf6df7df901a" url: "https://pub.dev" source: hosted - version: "4.6.2" + version: "5.0.2" geolocator_apple: dependency: transitive description: @@ -581,6 +629,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.13" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797 + url: "https://pub.dev" + source: hosted + version: "0.2.4" geolocator_platform_interface: dependency: transitive description: @@ -593,10 +649,10 @@ packages: dependency: transitive description: name: geolocator_web - sha256: "102e7da05b48ca6bf0a5bda0010f886b171d1a08059f01bfe02addd0175ebece" + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "4.1.3" geolocator_windows: dependency: transitive description: @@ -717,6 +773,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.4" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" hooks: dependency: transitive description: @@ -1021,6 +1085,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + url: "https://pub.dev" + source: hosted + version: "9.0.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -1077,54 +1157,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" - permission_handler: - dependency: transitive - description: - name: permission_handler - sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" - url: "https://pub.dev" - source: hosted - version: "11.4.0" - permission_handler_android: - dependency: transitive - description: - name: permission_handler_android - sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc - url: "https://pub.dev" - source: hosted - version: "12.1.0" - permission_handler_apple: - dependency: transitive - description: - name: permission_handler_apple - sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 - url: "https://pub.dev" - source: hosted - version: "9.4.7" - permission_handler_html: - dependency: transitive - description: - name: permission_handler_html - sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" - url: "https://pub.dev" - source: hosted - version: "0.1.3+5" - permission_handler_platform_interface: - dependency: transitive - description: - name: permission_handler_platform_interface - sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 - url: "https://pub.dev" - source: hosted - version: "4.3.0" - permission_handler_windows: - dependency: transitive - description: - name: permission_handler_windows - sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" - url: "https://pub.dev" - source: hosted - version: "0.2.1" petitparser: dependency: transitive description: @@ -1536,6 +1568,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.15" + timezone: + dependency: transitive + description: + name: timezone + sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b" + url: "https://pub.dev" + source: hosted + version: "0.11.0" typed_data: dependency: transitive description: @@ -1680,6 +1720,38 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + workmanager: + dependency: transitive + description: + name: workmanager + sha256: "065673b2a465865183093806925419d311a9a5e0995aa74ccf8920fd695e2d10" + url: "https://pub.dev" + source: hosted + version: "0.9.0+3" + workmanager_android: + dependency: transitive + description: + name: workmanager_android + sha256: "9ae744db4ef891f5fcd2fb8671fccc712f4f96489a487a1411e0c8675e5e8cb7" + url: "https://pub.dev" + source: hosted + version: "0.9.0+2" + workmanager_apple: + dependency: transitive + description: + name: workmanager_apple + sha256: "1cc12ae3cbf5535e72f7ba4fde0c12dd11b757caf493a28e22d684052701f2ca" + url: "https://pub.dev" + source: hosted + version: "0.9.1+2" + workmanager_platform_interface: + dependency: transitive + description: + name: workmanager_platform_interface + sha256: f40422f10b970c67abb84230b44da22b075147637532ac501729256fcea10a47 + url: "https://pub.dev" + source: hosted + version: "0.9.1+1" xdg_directories: dependency: transitive description: diff --git a/docs/DESIGN/product-specification.md b/docs/DESIGN/product-specification.md index 0163fa86..fd7e5515 100644 --- a/docs/DESIGN/product-specification.md +++ b/docs/DESIGN/product-specification.md @@ -6,7 +6,7 @@ ## Document Information **Version**: 1.0 -**Last Updated**: March 9, 2026 +**Last Updated**: March 14, 2026 **Purpose**: This document describes the functional behavior and user experience of KROW's mobile workforce management platform from a design perspective. --- @@ -1575,130 +1575,213 @@ Provide workers with a personalized dashboard showing shift summaries, recommend ## Staff: Clock In Out ### Purpose -Track worker attendance with location verification. Workers can check in and out of shifts, log break times, and enable commute tracking. +Track worker attendance with time-window enforcement and location verification. Workers view their scheduled shifts, check in using a swipe or NFC gesture, record break details during check-out, and can override location requirements with a written justification when GPS is unavailable. ### User Stories -#### Story 1: Check In to Shift with Location Verification +#### Story 1: View and Select Today's Shift **As a** worker -**I want to** register my arrival to a shift with automatic location verification \n**So that** I confirm my presence and initiate time tracking +**I want to** see my scheduled shifts for today and select which one I'm attending +**So that** I can clock in to the correct shift + +**Task Flow:** +1. Worker accesses the attendance tracking area +2. System loads the worker's shifts for the selected date (defaults to today) +3. Worker can navigate through a date strip spanning 3 days in the past, today, and 3 days ahead; only today is interactive +4. Each shift in the list shows: + - Role or position title + - Client name and venue location + - Scheduled start and end times + - Hourly rate +5. If already clocked in, the active shift is automatically highlighted +6. Otherwise, the most recent shift in the list is selected by default +7. Worker selects a shift to begin time-window and location verification for that venue + +**Information Required:** +- Shift selection (when multiple shifts are scheduled for the day) + +**Information Provided to User:** +- All shifts scheduled for today +- Which dates in the strip have shifts scheduled +- Which shift is currently active or selected + +**Edge Cases:** +- No shifts today: Message "No shifts scheduled today" with access to the shift marketplace +- Single shift: Auto-selected, no manual selection needed + +--- + +#### Story 2: Check In to Shift +**As a** worker +**I want to** register my arrival at a shift within the allowed time window +**So that** my work time is tracked from the moment I arrive ```mermaid graph TD - A[Start: Access Clock In] --> B[Load Today's Shifts] - B --> C{Multiple Shifts?} - C -->|Yes| D[Select Specific Shift] - C -->|No| E[Shift Auto-Selected] - D --> F[Request Location Permission] - E --> F - F --> G{Permission Granted?} - G -->|No| H[Error: Location Required] - G -->|Yes| I[Acquire Current Location] - I --> J[Calculate Distance from Venue] - J --> K{Within 500m?} - K -->|No| L[Warning: Too Far from Venue] - K -->|Yes| M[Enable Check-In] - M --> N{Confirmation Method?} - N -->|Swipe| O[Swipe Gesture Confirmation] - N -->|Action| P[Direct Confirmation] - O --> Q[Optional: Provide Check-In Notes] + A[Select Shift] --> B{Within Check-In Window?} + B -->|Too Early| C[Notice: Shows Time When Check-In Opens] + B -->|Window Open| D{GPS Permission?} + D -->|Denied| E[Permission Denied — Override Available] + D -->|Permanently Denied| F[Must Enable in Device Settings — Override Available] + D -->|Granted| G{GPS Service Enabled?} + G -->|Off| H[GPS Off — Override Available] + G -->|On| I[Acquiring Location — 30s Window] + I -->|Within 500m| J[Location Verified] + I -->|Outside 500m| K[Outside Geofence — Override Available] + I -->|Timeout| L[GPS Timeout — Retry or Override] + E --> M{Override with Justification?} + F --> M + H --> M + K --> M + L --> M + M -->|Submit Justification| J + M -->|Retry GPS| I + J --> N{Confirm Method?} + N -->|Swipe| O[Complete Swipe Gesture] + N -->|NFC| P[Complete NFC Tap] + O --> Q[Check-In Submitted] P --> Q - Q --> R[Submit Check-In] - R --> S[Success: Arrival Registered] - S --> T[Display Check-Out Capability
Show Break Logging
Show Commute Tracking] + Q --> R[Check-In Confirmed — Background Location Tracking Begins] ``` **Task Flow:** -1. User accesses attendance tracking area -2. System loads today's scheduled shifts -3. Shift selection:\n - If multiple shifts scheduled: User selects desired shift\n - If single shift: System auto-selects\n4. System requests location access permission (if not previously granted)\n5. User grants location access\n6. System acquires user's current geographical position\n7. System calculates distance from designated shift venue\n8. If within 500 meter radius:\n - Check-in capability becomes available\n - Distance information displayed (e.g., \"120m away\")\n9. User can register arrival via two methods:\n - **Gesture confirmation**: Swipe action across designated area\n - **Direct confirmation**: Direct action submission\n10. Optional notes interface appears (user can provide additional information or skip)\n11. User confirms arrival registration\n12. System confirms successful check-in: \"Checked in to [Shift Name]\"\n13. Interface updates to show:\n - Check-in timestamp\n - Break logging capability\n - Check-out capability\n - Optional: Commute tracking features\n\n**Information Required:**\n- Location permission (system request)\n- Shift selection (if multiple available)\n- Check-in confirmation (gesture or direct action)\n- Optional arrival notes (text)\n\n**Information Provided to User:**\n- Current distance from venue location\n- Location verification status\n- Check-in confirmation with precise timestamp\n- Updated interface showing departure registration capability\n\n**Edge Cases:**\n- **Location permission denied**: Error message \"Location access required to check in\" with guidance to device settings\n- **Distance exceeds threshold** (>500m): Warning \"You're too far from the venue. Move closer to check in.\" with actual distance displayed\n- **GPS signal unavailable**: Error \"Unable to determine location. Check your connection.\"\n- **Already registered arrival**: Display \"Already checked in at [time]\" with departure registration capability\n- **Incorrect shift selected**: User can modify selection before arrival confirmation\n- **Network connectivity issues**: Queue check-in for submission when connection restored - ---- - -#### Story 2: Log Break Time -**As a** worker -**I want to** record when I take breaks -**So that** my break time is accurately tracked and properly deducted from billable hours - -**Task Flow:** -1. User has registered arrival to shift -2. System displays break logging capability -3. User initiates break period recording -4. System displays running timer tracking break duration -5. User completes break and ends break period recording -6. System records total break duration -7. Optional: User can categorize break type (lunch, rest, etc.) +1. Worker selects a shift +2. System checks the current time against the shift's scheduled start time +3. If more than 15 minutes before shift start: check-in is not yet available; the time when it opens is displayed +4. Once within the check-in window, location verification begins automatically +5. System requests GPS permission if not already granted +6. GPS attempts to acquire the worker's current location (up to 30-second window) +7. System calculates distance from the shift's venue coordinates +8. If within 500 metres: check-in is enabled +9. If location cannot be verified (outside geofence, GPS timeout, permission issues): worker may provide a written justification to override — see Story 3 +10. With location verified or overridden, worker confirms check-in using one of two methods: + - **Swipe confirmation**: Drag a slider across at least 80% of its range + - **NFC confirmation**: Tap device to the NFC reader at the venue +11. System submits check-in to the backend +12. System confirms check-in with a timestamp +13. Background location tracking begins automatically for the duration of the shift **Information Required:** -- Break start (user-initiated) -- Break end (user-initiated) -- Optional: Break type classification +- GPS permission (system request) +- Shift selection (if multiple shifts today) +- Check-in confirmation (swipe gesture or NFC tap) **Information Provided to User:** -- Active break timer display -- Total break time recorded -- Confirmation of break logging +- Time window status: how long until check-in opens, or confirmation that it is open +- Location verification status and current distance from venue +- Check-in confirmation with exact timestamp +- Confirmation that background location tracking is now active **Edge Cases:** -- Forgot to end break: Capability to manually adjust break duration -- Multiple breaks: System tracks each break period independently with cumulative tracking -- System interruption: Break timer continues in background, recovers on re-access +- **Too early to check in**: Check-in unavailable; exact time when it becomes available is shown +- **GPS permission denied**: Worker can open device settings or use justification override +- **GPS permanently denied**: Worker must enable location in device settings; override also available +- **GPS service off**: Worker directed to enable device GPS; override also available +- **Outside geofence**: Distance from venue displayed; override with written justification permitted +- **GPS timeout (30 seconds)**: Worker can retry location check or use justification override +- **Already checked in**: Check-out flow shown instead; prior check-in time displayed --- -#### Story 3: Check Out of Shift +#### Story 3: Override Location Requirement **As a** worker -**I want to** register my departure from a shift -**So that** my work time is fully recorded for compensation +**I want to** clock in even when location verification cannot be completed +**So that** my attendance is recorded despite GPS issues beyond my control **Task Flow:** -1. User has registered arrival and completed work -2. User initiates departure registration -3. Optional notes interface appears -4. User provides additional information (if desired) or skips -5. User confirms departure -6. System verifies location again (same 500m proximity requirement) -7. System records departure timestamp -8. System calculates total work time (arrival - departure minus breaks) -9. System presents work summary displaying: - - Arrival time - - Departure time - - Total hours worked - - Break time deducted - - Estimated compensation (if available) - -**Information Required:**\n- Departure confirmation\n- Optional departure notes (text)\n- Location verification\n\n**Information Provided to User:**\n- Departure confirmation with precise timestamp\n- Comprehensive work summary (hours worked, breaks taken, estimated pay)\n- Complete time tracking information\n\n**Edge Cases:**\n- Departure distance exceeds venue threshold: Warning message but may allow with approval workflow\n- Forgot to register departure: Supervisor manual adjustment capability or automatic departure at scheduled shift end\n- Early departure: Warning \"Shift not yet complete. Confirm early check-out?\" with acknowledgment required\n- Network issues: Queue departure registration for submission when connected - ---- - -#### Story 4: Enable Commute Tracking -**As a** worker -**I want to** enable commute tracking -**So that** clients can monitor my estimated arrival time - -**Task Flow:** -1. After registering shift arrival, user sees commute tracking capability -2. User enables commute tracking -3. System begins continuous location monitoring -4. System calculates estimated time of arrival to venue -5. ETA information displayed to user and visible to client -6. System provides real-time updates of distance and ETA -7. When user proximity reaches venue (distance < 50m), system automatically disables commute mode +1. Location verification fails or cannot complete (permission denied, GPS off, outside 500m, or 30-second timeout) +2. System presents the reason for the location issue and an option to proceed without verification +3. Worker requests to proceed without location verification +4. System presents a written justification form +5. Worker provides a written explanation of why location verification is not possible +6. Justification must be non-empty before submission is allowed +7. Worker submits justification +8. System marks the attendance record as location-overridden and stores the justification note +9. Check-in confirmation (swipe or NFC) becomes available +10. Worker proceeds with normal check-in flow **Information Required:** -- Commute tracking preference (enabled/disabled) -- Continuous location updates +- Written justification (required; cannot be empty) **Information Provided to User:** +- Explanation of the location issue encountered +- Confirmation that the override justification has been recorded +- Confirmation that check-in can now proceed + +**Edge Cases:** +- Empty justification: Submission is prevented until text is provided +- Justification is stored alongside the attendance record for administrative review + +--- + +#### Story 4: Check Out with Break Details +**As a** worker +**I want to** record my departure and any break taken during my shift +**So that** my total worked hours and compensation are calculated accurately + +**Task Flow:** +1. Worker has previously checked in and is within the check-out time window (within 15 minutes of shift end) +2. If attempting to check out too early: check-out is not yet available; the time when it opens is displayed +3. Same location verification and override rules apply as check-in (Story 2 and Story 3) +4. Worker confirms check-out using swipe or NFC (same methods as check-in) +5. System presents a multi-step break details form: + - **Step 1**: Did you take a lunch break? (Yes / No) + - **Step 2a — if Yes**: Select break start time and break end time (available in 15-minute increments) + - **Step 2b — if No**: Select the reason no break was taken (from a predefined list) + - **Step 3**: Optional additional notes about the shift + - **Step 4**: Review summary of all submitted details +6. Worker confirms the summary +7. System submits check-out and break information to the backend +8. System confirms shift is complete +9. Background location tracking stops automatically + +**Information Required:** +- Check-out confirmation (swipe gesture or NFC tap) +- Break taken: Yes or No +- If Yes: break start time and break end time (15-minute increments) +- If No: reason for no break (selection from predefined options) +- Optional: additional shift notes + +**Information Provided to User:** +- Time window status (when check-out becomes available, or confirmed open) +- Break detail form with step-by-step guidance +- Summary of all submitted information before final confirmation +- Check-out confirmation with exact timestamp +- Shift completion status + +**Edge Cases:** +- **Too early to check out**: Check-out unavailable; exact time when it becomes available is shown +- **Geofence and override rules**: Same location verification and override flow as check-in applies +- **Break time selection**: Times are chosen from 15-minute slot options, not free-text entry +- **Network issue**: Check-out request queued for submission when connection is restored + +--- + +#### Story 5: Track Commute to Shift +**As a** worker +**I want to** share my estimated arrival time before a shift +**So that** the client knows when to expect me + +**Task Flow:** +1. Before a shift begins, the system offers commute tracking +2. Worker consents to sharing commute location data +3. System monitors the worker's distance to the venue and calculates estimated time of arrival +4. ETA information is made visible to the client +5. Worker is shown their current distance to the venue and estimated arrival time +6. When the worker arrives within range of the venue, commute tracking automatically deactivates + +**Information Required:** +- Consent to share commute location + +**Information Provided to User:** +- Current distance to the shift venue - Estimated arrival time (e.g., "Arriving in 12 minutes") -- Distance to venue (e.g., "2.3 km away") -- Real-time progress updates +- Confirmation when commute tracking deactivates upon arrival **Edge Cases:** -- Location tracking interruption: System displays last known position -- Arrival but ETA persisting: Auto-clears when within 50m proximity -- Privacy preference: User can disable tracking at any time -- Route changes: ETA automatically recalculates based on current position +- Location permission required for commute tracking to function +- Worker can withdraw consent and disable commute tracking at any time +- Route changes cause estimated arrival time to automatically recalculate ---