From aa6b78a14398c81dcb86bfb8565339a9812c47eb Mon Sep 17 00:00:00 2001 From: Thomas Laure Date: Sun, 12 Apr 2026 21:24:38 +0200 Subject: [PATCH 1/7] chore(tooling): enrich agent rules, skill definitions and workflow Expanded all skill and command definitions with concrete workflows, local-project rules, and quality-gate references. Enriched patterns.md with hexagonal architecture and caregiver-flow conventions. Co-Authored-By: Claude Sonnet 4.6 --- .claude/commands/fall-guardian/bug-fix.md | 39 ++- .../fall-guardian/improve-instructions.md | 50 +++- .claude/commands/fall-guardian/new-feature.md | 52 +++- .../commands/fall-guardian/prepare-commit.md | 43 ++- .../commands/fall-guardian/review-change.md | 37 ++- .../commands/fall-guardian/scan-project.md | 44 +++- .../commands/fall-guardian/security-review.md | 50 +++- .../commands/fall-guardian/verify-quality.md | 43 ++- .claude/patterns.md | 246 ++++++++++++++++-- .claude/skills/bug-fix/SKILL.md | 34 ++- .claude/skills/improve-instructions/SKILL.md | 40 ++- .claude/skills/new-feature/SKILL.md | 36 ++- .claude/skills/prepare-commit/SKILL.md | 45 +++- .claude/skills/review-change/SKILL.md | 35 ++- .claude/skills/scan-project/SKILL.md | 31 ++- .claude/skills/security-review/SKILL.md | 34 ++- .claude/skills/verify-quality/SKILL.md | 40 ++- WORKFLOW.md | 10 + 18 files changed, 797 insertions(+), 112 deletions(-) diff --git a/.claude/commands/fall-guardian/bug-fix.md b/.claude/commands/fall-guardian/bug-fix.md index 675d6a3..c4b05b3 100644 --- a/.claude/commands/fall-guardian/bug-fix.md +++ b/.claude/commands/fall-guardian/bug-fix.md @@ -1,8 +1,31 @@ -Fix the bug at the correct layer, not with a local workaround. - -Steps: -- reproduce or understand the failing path -- trace ownership across Flutter/native/backend if cross-platform -- implement the smallest durable fix -- add a regression test when practical -- verify the narrowest relevant checks, then broader checks if needed +Fix a Symfony/API Platform bug using a small, repeatable regression-first workflow. + +Bug report or scope: `$ARGUMENTS` + +Execution order: +1. Reproduce the bug from the user report, failing test, logs, or current behavior. +2. Find the narrowest code path that explains the failure. +3. Identify the root cause before changing code. +4. Implement the smallest fix that resolves the bug cleanly. +5. Add or update a regression test in the same session. +6. Run the relevant repository quality gates before reporting completion. + +Default expectations unless the repo clearly differs: +- Prefer the smallest understandable fix over broad refactoring. +- Keep the fix easy for a human reviewer to understand. +- If API Platform already provides the correct behavior directly, use it instead of adding extra layers. +- If performance and readability conflict and there is no measured bottleneck, choose readability. + +Checklist: +1. State expected behavior versus actual behavior. +2. Point to the concrete failing path: endpoint, handler, repository, listener, or adapter. +3. Reuse local patterns instead of introducing a new structure during a bug fix. +4. Add one regression test for the reproduced scenario. +5. Add extra coverage only for closely related edge cases. +6. Verify with the commands exposed by `Makefile`. + +Avoid: +- mixing unrelated cleanup into the fix +- speculative refactors during an urgent bug fix +- skipping the regression test unless the repo truly cannot express one +- introducing extra indirection for a local issue diff --git a/.claude/commands/fall-guardian/improve-instructions.md b/.claude/commands/fall-guardian/improve-instructions.md index e8141ce..38df898 100644 --- a/.claude/commands/fall-guardian/improve-instructions.md +++ b/.claude/commands/fall-guardian/improve-instructions.md @@ -1,8 +1,42 @@ -Improve repo instructions only when there is durable evidence of drift. - -Steps: -- identify the stable guidance gap -- propose the change first -- update the canonical file -- keep pointer files minimal -- avoid adding one-off local anecdotes +Review the repository guidance and propose improvements to instruction files without editing them silently. + +Scope or context: `$ARGUMENTS` + +Target files: +- `AGENTS.md` +- `CLAUDE.md` +- `.claude/rules/*.md` +- `.claude/patterns.md` +- `.claude/commands/fall-guardian/*.md` +- `.claude/skills/*/SKILL.md` + +Workflow: +1. Inspect the current repository shape, quality gates, and recent workflow expectations. +2. Look for instruction drift: + - repeated corrections in recent work + - `Makefile`, `composer.json`, or repo structure changes + - architecture or testing conventions no longer reflected in instructions + - duplicated or conflicting guidance +3. Classify each candidate update: + - policy + - workflow + - pattern + - project fact + - temporary note +4. Keep only durable, reusable updates. Ignore one-off preferences. +5. Produce a proposed patch and rationale. +6. Ask for confirmation before editing any instruction file. + +Rules: +- Never edit instruction files silently. +- `AGENTS.md` remains the canonical source of repository-specific rules. +- `CLAUDE.md` must stay a thin pointer. +- Update `.claude` files only when the improvement is reusable and not just project noise. +- If commands and skills overlap, keep them aligned with the same underlying rules and patterns. + +Output format: +- `Detected drift` +- `Proposed updates` +- `Why each update is useful` +- `Patch preview` +- `Confirmation request` diff --git a/.claude/commands/fall-guardian/new-feature.md b/.claude/commands/fall-guardian/new-feature.md index 04c0215..e65a528 100644 --- a/.claude/commands/fall-guardian/new-feature.md +++ b/.claude/commands/fall-guardian/new-feature.md @@ -1,9 +1,43 @@ -Implement a new feature in the smallest clean slice. - -Steps: -- scan the relevant architecture first -- choose the right layer for each change -- preserve coordinator/service ownership -- add or update tests -- update docs if runtime behavior changes -- verify the relevant checks before finishing +Implement a new Symfony/API Platform feature by mirroring the local repository patterns. + +User request: `$ARGUMENTS` + +Execution order: +1. Run the equivalent of `/fall-guardian:scan-project` if context is incomplete. +2. Find one nearby example in the same area and mirror its structure. +3. Implement the smallest coherent slice that satisfies the request. +4. Write tests in the same session. +5. Run the repository quality gates before reporting completion. + +Default expectations unless the repo clearly differs: +- Follow SOLID principles. +- Prefer clean architecture and hexagonal boundaries when the project already uses them. +- If API Platform already provides a direct, readable solution for the requested behavior, use the API Platform feature instead of adding extra layers. +- Symfony entrypoints stay thin. +- Input validation happens at the DTO or request boundary. +- Business logic lives in handlers, use-cases, or domain services. +- Repositories handle persistence only. +- Output shaping is explicit through DTOs, resources, or entity serialization. +- Prefer simple, readable code over clever or highly optimized code. +- If performance and readability conflict and there is no measured bottleneck, choose readability. +- Keep the result easy for a human reviewer to follow. + +Checklist: +1. Confirm the target flow: API Platform native, layered write side, or both. +2. Reuse existing naming and file placement conventions. +3. Keep `declare(strict_types=1);` and modern PHP syntax. +4. Add or update validation at the input boundary. +5. Keep exceptions and HTTP error mapping aligned with the existing project. +6. Add the right tests: + - unit tests for behavior and orchestration + - integration tests when persistence behavior changes + - API tests when endpoint behavior changes +7. Verify with the commands exposed by `Makefile`. + +Avoid: +- business logic in controllers or framework entrypoints +- new dependencies without explicit approval +- schema changes without explicit approval +- adding hexagonal or CQRS indirection when API Platform can solve the case directly and cleanly +- premature optimization or indirection that hurts readability +- project reshaping when the request only needs a local change diff --git a/.claude/commands/fall-guardian/prepare-commit.md b/.claude/commands/fall-guardian/prepare-commit.md index dd638d6..9473b0e 100644 --- a/.claude/commands/fall-guardian/prepare-commit.md +++ b/.claude/commands/fall-guardian/prepare-commit.md @@ -1,6 +1,37 @@ -Before preparing a commit: -- confirm the worktree only contains intended changes -- group commits logically -- use Conventional Commits -- summarize remaining risks or limitations -- do not commit or push without explicit confirmation in the current conversation +Prepare a commit for the current changes using Conventional Commits and repo-aware verification notes. + +Scope or context: `$ARGUMENTS` + +Workflow: +1. Inspect `git status`, the current branch, and the current diff. +2. Stage the intended files with `git add`. +3. Run the repository quality checks relevant to the current change, using the real project gates from `Makefile`. +4. Review the change for correctness, architecture, validation, regressions, and test completeness. +5. Review the change with a security lens: auth/authz, input validation, secrets, external calls, and data exposure. +6. If the current branch is `main`, `master`, `develop`, or another protected/shared branch, create a dedicated branch before committing. +7. Build a Conventional Commit message that matches the actual change scope. +8. Prepare PR-ready notes that explain: + - what changed + - why it changed + - how it was implemented + - what was verified + - remaining risks or follow-up points +9. Prepare a verification checklist based on the real project gates from `Makefile`. +10. If the user explicitly asks to commit, ask for confirmation before running `git commit`. +11. If the user explicitly asks to push, ask for confirmation before running `git push`. + +Rules: +- Never commit or push silently. +- Treat `git commit` and `git push` as confirmation-required actions even when the user previously asked for preparation work. +- Stage only the files that belong to the intended change. +- Prefer a short, accurate Conventional Commit title over a clever one. +- Do not invent verification steps that do not exist in the repository. +- If quality, review, or security checks reveal blockers, surface them before proposing the final commit. + +Output format: +- `Pre-commit checks` +- `Suggested branch` +- `Suggested commit title` +- `Suggested commit body` +- `PR notes` +- `Verification checklist` diff --git a/.claude/commands/fall-guardian/review-change.md b/.claude/commands/fall-guardian/review-change.md index f484a3b..fa19be9 100644 --- a/.claude/commands/fall-guardian/review-change.md +++ b/.claude/commands/fall-guardian/review-change.md @@ -1,8 +1,29 @@ -Review the changed files for: -- correctness -- regressions -- lifecycle safety -- platform parity -- missing tests - -Findings first. Keep summaries brief. +Perform a structured code review for a Symfony/API Platform change. + +Review scope: `$ARGUMENTS` + +If the scope is omitted, inspect the current git diff. + +Review checklist: +1. Confirm the change respects the repository architecture and `AGENTS.md`. +2. Check that entrypoints remain orchestration-only. +3. Check that business rules stay in handlers, use-cases, or domain services. +4. Check validation at the input boundary. +5. Check persistence code for leaked business logic. +6. Check error handling and API behavior consistency. +7. Check test completeness for the changed behavior. +8. Check that the change can pass the repo quality gates. + +Prioritize findings: +- correctness bugs +- security regressions +- architectural regressions +- missing validation or tests +- maintainability issues + +Response format: +- findings first, ordered by severity +- then open questions or assumptions +- then a short summary only if useful + +Do not focus on style nits already covered by formatters or static analysis unless they expose real risk. diff --git a/.claude/commands/fall-guardian/scan-project.md b/.claude/commands/fall-guardian/scan-project.md index 734b3a7..064ebf5 100644 --- a/.claude/commands/fall-guardian/scan-project.md +++ b/.claude/commands/fall-guardian/scan-project.md @@ -1,12 +1,32 @@ -Scan the repository before proposing changes. - -Read: -- `AGENTS.md` -- `PROJECT_CONTEXT.md` -- `WORKFLOW.md` -- `CURRENT_STATUS.md` - -Then inspect only the code paths relevant to the request and summarize: -- current architecture touchpoints -- likely risk areas -- verification needed +Review this Symfony/API Platform repository and produce an implementation-ready map. + +User request: `$ARGUMENTS` + +Workflow: +1. Read `AGENTS.md`, `CLAUDE.md`, `README.md`, `composer.json`, and `Makefile`. +2. Inspect the project layout before proposing any change: + - `src/` + - `config/` + - `tests/` + - `features/` + - `migrations/` +3. Detect the active conventions: + - Symfony and PHP versions + - API Platform usage style + - Docker/FrankenPHP setup + - layered, CQRS, or API Platform native flows + - test stack and quality gates +4. Identify the nearest existing pattern for the requested area. +5. Call out project-specific constraints that matter before coding. + +Output format: +- `Context`: 4-8 bullets with relevant architecture and tooling facts +- `Existing patterns`: file paths worth mirroring +- `Files likely to change`: exact paths or tight glob patterns +- `Risks`: regressions, hidden coupling, or prerequisites +- `Implementation plan`: short numbered list + +Rules: +- Prefer local project patterns over Symfony defaults. +- Do not invent new folders or layers when the repo already has a clear shape. +- Surface any policy in `AGENTS.md` that requires explicit confirmation before changes. diff --git a/.claude/commands/fall-guardian/security-review.md b/.claude/commands/fall-guardian/security-review.md index e7be623..2b1e9ff 100644 --- a/.claude/commands/fall-guardian/security-review.md +++ b/.claude/commands/fall-guardian/security-review.md @@ -1,9 +1,41 @@ -Review the relevant change for: -- secrets exposure -- unsafe input handling -- insecure persistence -- backend authorization gaps -- unsafe outbound communication -- cross-device trust assumptions - -Call out concrete risks before general advice. +Perform a focused security review for a Symfony/API Platform change. + +Review scope: `$ARGUMENTS` + +If the scope is omitted, inspect the current git diff. + +Security checklist: +1. Authentication: + - protected routes are explicit + - anonymous access is intentional +2. Authorization: + - server-side access checks exist where needed + - object ownership or tenant boundaries are enforced +3. Input handling: + - request DTOs or input boundaries validate user input + - identifiers, enums, URLs, and uploaded data are constrained +4. External interactions: + - secrets come from configuration, not code + - outbound calls are bounded and validated + - user-provided URLs or remote targets are handled safely +5. Data exposure: + - only intended fields are returned + - stack traces, tokens, and internal details are not leaked +6. Side effects: + - unsafe actions use the correct HTTP semantics + - validation happens before persistence or outbound calls +7. Tests: + - negative tests exist for forbidden or invalid paths when relevant + +Prioritize findings: +- auth or authz bypass +- sensitive data leaks +- unsafe input or external call handling +- missing negative tests on protected behavior + +Response format: +- findings first, ordered by severity +- then open questions or assumptions +- then a short hardening summary + +Tie every finding to concrete code paths and missing or incorrect enforcement. diff --git a/.claude/commands/fall-guardian/verify-quality.md b/.claude/commands/fall-guardian/verify-quality.md index 2674a8e..b853e58 100644 --- a/.claude/commands/fall-guardian/verify-quality.md +++ b/.claude/commands/fall-guardian/verify-quality.md @@ -1,8 +1,35 @@ -Run the narrowest relevant verification first, then broaden if needed. - -Typical checks: -- `make check` -- Flutter targeted tests -- backend PHPStan -- backend PHPUnit -- platform-specific build or manual verification when native code changed +Verify a Symfony/API Platform change using the repository's real quality gates. + +Verification scope: `$ARGUMENTS` + +Workflow: +1. Read `Makefile`, `composer.json`, and `AGENTS.md`. +2. Identify the canonical commands exposed by the repo. +3. Prefer the project wrappers over raw vendor commands. +4. Run the narrowest relevant tests first, then the broader required checks. +5. Report failures with the command, impacted files, and minimal fix direction. + +Typical order: +1. `make quality` (runs lint, analyse, rector together) +2. `make tests-unit` +3. `make tests-integration` +4. `make tests` +5. `make tests-api` +6. `make security` + +For targeted runs only (skip if running `make quality`): +- `make lint` +- `make analyse` +- `make rector` + +Output format: +- `Commands run` +- `Pass/fail summary` +- `Remaining gaps` + +Rules: +- If a command is unavailable, say so instead of inventing a substitute. +- If Docker is required, use the project commands or wrappers already defined. +- If a change affects endpoint behavior, do not stop at unit tests only. +- When fixing PHPStan issues, prefer correcting the code, types, or annotations instead of changing `phpstan.neon`. +- Treat edits to `phpstan.neon` as exceptional and ask first unless the user explicitly requested a PHPStan configuration change. diff --git a/.claude/patterns.md b/.claude/patterns.md index b0a4441..940b715 100644 --- a/.claude/patterns.md +++ b/.claude/patterns.md @@ -3,10 +3,9 @@ Use these patterns as generic guidance. Always prefer nearby repository examples when they exist. Design intent: - - apply SOLID principles without adding unnecessary abstraction -- keep coordinator-driven workflow readable -- use native platform or API Platform features directly when they already solve the need cleanly +- keep clean architecture and hexagonal boundaries readable +- use native API Platform features directly when they already solve the need cleanly - choose readability over premature optimization - write code that is easy for a human reviewer to understand @@ -22,20 +21,237 @@ Design intent: - bridge validates and translates them into shared app events - platform adapters do not duplicate Flutter workflow decisions -## Backend Command Flow +## Cross-Platform Contract Rule + +- new event/method/key/config must be checked across Flutter, Android, iOS, Wear OS, watchOS, and backend when relevant +- avoid adding local-only assumptions that break shared fall timestamp or cancel behavior -- API Platform resource/processor or controller receives request -- application service validates and orchestrates -- persistence happens explicitly -- Messenger worker handles slow outbound delivery +--- -## Test Selection +## API Platform Native Read Resource -- coordinator or repository change -> Flutter unit/widget test -- Android/iOS/watch runtime change -> platform test or at least targeted manual verification note -- backend delivery or API behavior change -> backend unit/integration test +Use this pattern when the read side is served directly by API Platform without an extra application layer. -## Cross-Platform Contract Rule +```php + ['feature:read']], + paginationEnabled: true, +)] +#[ApiFilter(SearchFilter::class, properties: ['name' => 'partial'])] +class Feature +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['feature:read'])] + private int $id; + + #[ORM\Column(length: 255)] + #[Groups(['feature:read'])] + private string $name; + + public function getId(): int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } +} +``` + +Rules: +- Use serialization groups to control exposed fields explicitly. +- Add filters as `#[ApiFilter]` attributes rather than custom DQL. +- Do not add an application handler for read-only API Platform operations unless the query logic genuinely belongs in the domain. + +## Handler / Use Case + +```php +name); + + $this->repository->save($feature); + + return $feature; + } +} +``` + +## Command / Query DTO + +```php +repository = $this->createMock(FeatureRepositoryInterface::class); + } + + public function testInvokeWithValidInputSavesFeature(): void + { + $this->repository + ->expects($this->once()) + ->method('save'); + + $handler = new CreateFeatureHandler($this->repository); + + $result = $handler(new CreateFeatureInput('Example')); + + $this->assertSame('Example', $result->name()); + } +} +``` diff --git a/.claude/skills/bug-fix/SKILL.md b/.claude/skills/bug-fix/SKILL.md index fcdd816..9180e52 100644 --- a/.claude/skills/bug-fix/SKILL.md +++ b/.claude/skills/bug-fix/SKILL.md @@ -1,6 +1,32 @@ +--- +name: bug-fix +description: Use this skill when the user reports broken behavior, errors, regressions, failing tests, or asks to fix a bug. Trigger on requests like "this is broken", "fix this bug", "the test fails", or "there is a regression". +--- + # Bug Fix -- Reproduce or understand the failing path. -- Fix the root cause in the owning layer. -- Add a regression test when practical. -- Verify the relevant checks before finishing. +Use this skill for a small, repeatable regression-first bug-fix workflow. + +Read first: +- `AGENTS.md` +- `.claude/rules/architecture.md` +- `.claude/rules/testing.md` +- `.claude/rules/security.md` + +Read as needed: +- `.claude/patterns.md` +- nearby files in the failing path + +Workflow: +1. Reproduce the issue from the report, logs, failing test, or current behavior. +2. Isolate the narrowest code path that explains the failure. +3. Identify the root cause before editing code. +4. Implement the smallest clean fix. +5. Add or update a regression test when the bug can be reproduced automatically. +6. Run the relevant quality gates before reporting completion. + +Rules: +- Reuse local patterns instead of introducing a new structure. +- Prefer the smallest understandable fix over broad refactoring. +- If API Platform already provides the correct behavior directly, use it. +- Choose readability over performance unless there is a measured bottleneck. diff --git a/.claude/skills/improve-instructions/SKILL.md b/.claude/skills/improve-instructions/SKILL.md index 640df66..d6c7d11 100644 --- a/.claude/skills/improve-instructions/SKILL.md +++ b/.claude/skills/improve-instructions/SKILL.md @@ -1,6 +1,38 @@ +--- +name: improve-instructions +description: Use this skill when the user asks to improve agent instructions, update Claude or Codex guidance, review drift in AGENTS.md or .claude files, or keep the repo guidance aligned with how the project actually evolves. +--- + # Improve Instructions -- Change instruction files only for stable, reusable guidance. -- Update the canonical file first. -- Keep pointer files minimal. -- Avoid temporary or local-only details. +Use this skill to propose improvements to repository instruction files in a continuous-improvement workflow. + +Read first: +- `AGENTS.md` +- `CLAUDE.md` +- `.claude/rules/architecture.md` +- `.claude/rules/testing.md` +- `.claude/rules/security.md` + +Read as needed: +- `.claude/patterns.md` +- `.claude/commands/fall-guardian/*.md` +- `.claude/skills/*/SKILL.md` +- `Makefile` +- `composer.json` +- `README.md` + +Workflow: +1. Inspect the repository and current guidance. +2. Identify durable drift between the codebase, workflows, and instruction files. +3. Keep only reusable improvements, not one-off comments or temporary context. +4. Propose exact updates for `AGENTS.md`, `.claude/rules`, `.claude/patterns.md`, commands, or skills. +5. Present the proposed patch and rationale. +6. Ask for confirmation before applying any edit. + +Rules: +- Never edit instruction files silently. +- Treat `AGENTS.md` as the canonical project-specific source of truth. +- Keep `CLAUDE.md` as a pointer only. +- Avoid adding project noise or temporary guidance to shared instruction files. +- Keep commands and skills aligned when a workflow exists in both forms. diff --git a/.claude/skills/new-feature/SKILL.md b/.claude/skills/new-feature/SKILL.md index 05749c3..c24fe09 100644 --- a/.claude/skills/new-feature/SKILL.md +++ b/.claude/skills/new-feature/SKILL.md @@ -1,7 +1,33 @@ +--- +name: new-feature +description: Use this skill when the user asks to add, create, implement, or build new functionality in a Symfony/API Platform project. Trigger on requests like "add an endpoint", "implement a feature", "create a command", or "build this behavior". +--- + # New Feature -- Scan the relevant architecture first. -- Add behavior in the correct layer. -- Keep workflow out of UI/controllers. -- Add or update tests. -- Update docs if runtime behavior changed. +Use this skill to implement new functionality while staying aligned with the local project architecture. + +Read first: +- `AGENTS.md` +- `.claude/rules/architecture.md` +- `.claude/rules/testing.md` +- `.claude/rules/security.md` + +Read as needed: +- `.claude/patterns.md` +- nearby files in the same domain or flow + +Workflow: +1. If context is incomplete, inspect the repo shape and find a nearby example first. +2. Mirror the local naming, placement, and flow instead of generating framework-default code. +3. Follow SOLID pragmatically and keep clean or hexagonal boundaries readable. +4. If API Platform already supports the requested behavior cleanly, use the API Platform feature directly instead of adding extra layers. +5. Keep the implementation simple and easy for a human reviewer to follow. +6. Add the right tests in the same session. +7. Run the repository quality gates before reporting completion. + +Rules: +- Keep entrypoints thin. +- Keep business rules in handlers, use-cases, or domain services. +- Keep repositories focused on persistence. +- Prefer readability over premature optimization. diff --git a/.claude/skills/prepare-commit/SKILL.md b/.claude/skills/prepare-commit/SKILL.md index f6aea3a..6a5fcd9 100644 --- a/.claude/skills/prepare-commit/SKILL.md +++ b/.claude/skills/prepare-commit/SKILL.md @@ -1,7 +1,42 @@ +--- +name: prepare-commit +description: Use this skill when the user asks to prepare a commit, write a commit message, stage files, create a branch, prepare PR notes, or mentions Conventional Commits. Trigger on requests like "prepare the commit", "write the commit message", "stage this and prepare a PR", or "help me commit these changes". +--- + # Prepare Commit -- Confirm the diff is intentional. -- Split commits logically. -- Use Conventional Commits. -- Note remaining risks in the PR description. -- Do not commit or push without explicit confirmation in the current conversation. +Use this skill to prepare a commit and PR context without silently performing protected git actions. + +Read first: +- `AGENTS.md` +- `Makefile` + +Read as needed: +- `git status` +- `git diff` +- `git log` + +Workflow: +1. Inspect the current branch and working tree. +2. Stage the intended files with `git add`. +3. Run the repository quality checks relevant to the current change. +4. Review the change for correctness, architecture, validation, regressions, and test coverage. +5. Review the change with a security lens. +6. If the current branch is `main`, `master`, `develop`, or another protected/shared branch, create a dedicated working branch first. +7. Build a Conventional Commit title and optional body from the actual change. +8. Prepare PR-ready notes: + - what + - why + - how + - tests or verification + - risks or follow-up +9. Prepare a checkbox-style verification list using the repo's actual quality gates. +10. If the user explicitly asks to commit, ask for confirmation before running `git commit`. +11. If the user explicitly asks to push, ask for confirmation before running `git push`. + +Rules: +- Never commit or push without explicit confirmation in the current conversation. +- Do not assume all changed files belong to the intended commit. +- Keep the commit message human-readable and reviewer-friendly. +- Prefer the repository's real verification commands over generic checklist items. +- If pre-commit checks find blockers, report them before proposing the final commit. diff --git a/.claude/skills/review-change/SKILL.md b/.claude/skills/review-change/SKILL.md index aa47cfd..4de6eb1 100644 --- a/.claude/skills/review-change/SKILL.md +++ b/.claude/skills/review-change/SKILL.md @@ -1,5 +1,34 @@ +--- +name: review-change +description: Use this skill when the user asks for a code review, a correctness pass, a check before PR, or asks whether a Symfony/API Platform change follows project conventions. Trigger on "review this", "check my changes", or "is this implementation correct?". +--- + # Review Change -- Review for correctness, regressions, lifecycle safety, and platform parity. -- Prioritize real findings. -- Keep summaries brief. +Use this skill to review a change for correctness, architecture, and test completeness. + +Read first: +- `AGENTS.md` +- `.claude/rules/architecture.md` +- `.claude/rules/testing.md` +- `.claude/rules/security.md` + +Read as needed: +- `.claude/patterns.md` +- the relevant diff and nearby files + +Workflow: +1. Inspect the diff or the requested scope. +2. Check architecture and layer ownership. +3. Check validation, persistence boundaries, and error handling consistency. +4. Check test coverage for the changed behavior. +5. Check whether the change is likely to pass the repository quality gates. + +Response format: +- findings first, ordered by severity +- then assumptions or open questions +- then a short summary only if useful + +Rules: +- Focus on correctness, security, regressions, and missing tests. +- Do not spend time on style nits already covered by tooling unless they expose real risk. diff --git a/.claude/skills/scan-project/SKILL.md b/.claude/skills/scan-project/SKILL.md index f6566fc..915a037 100644 --- a/.claude/skills/scan-project/SKILL.md +++ b/.claude/skills/scan-project/SKILL.md @@ -1,5 +1,30 @@ +--- +name: scan-project +description: Use this skill when the user asks to explore, inspect, understand, map, or analyze a Symfony/API Platform repository before making changes. Trigger on requests like "look at this repo", "understand this project", "map the architecture", or "where should this change go?". +--- + # Scan Project -- Read `AGENTS.md`, `PROJECT_CONTEXT.md`, `WORKFLOW.md`, and `CURRENT_STATUS.md`. -- Inspect only the code paths relevant to the user request. -- Identify ownership boundaries, likely regression risks, and required verification. +Use this skill to build an implementation-ready map of the repository before coding. + +Read first: +- `AGENTS.md` +- `.claude/rules/architecture.md` +- `.claude/rules/testing.md` + +Read as needed: +- `README.md` +- `composer.json` +- `Makefile` +- `.claude/patterns.md` + +Workflow: +1. Inspect the project shape: `src/`, `config/`, `tests/`, `features/`, `migrations/`. +2. Identify the active conventions: Symfony version, API Platform usage, Docker/FrankenPHP setup, quality gates, and testing stack. +3. Find the nearest local example for the area the user wants to change. +4. Summarize architecture, likely files to touch, and main risks. + +Rules: +- Prefer local repository patterns over Symfony defaults. +- Do not invent new layers or folders during the exploration phase. +- Surface any `AGENTS.md` rule that constrains implementation decisions. diff --git a/.claude/skills/security-review/SKILL.md b/.claude/skills/security-review/SKILL.md index 981df0c..5e37975 100644 --- a/.claude/skills/security-review/SKILL.md +++ b/.claude/skills/security-review/SKILL.md @@ -1,4 +1,34 @@ +--- +name: security-review +description: Use this skill when the user asks for a security review, mentions auth/authz concerns, input validation, secret handling, SSRF, unsafe external calls, or wants a stricter review of a Symfony/API Platform change. +--- + # Security Review -- Look for secrets exposure, unsafe input handling, insecure persistence, authorization gaps, and unsafe outbound communication. -- Treat watch/phone/backend messages as untrusted until validated. +Use this skill to review a change with a security-first lens. + +Read first: +- `AGENTS.md` +- `.claude/rules/security.md` +- `.claude/rules/architecture.md` +- `.claude/rules/testing.md` + +Read as needed: +- `.claude/patterns.md` +- the relevant diff and nearby files + +Workflow: +1. Inspect authentication and authorization boundaries. +2. Check input validation before side effects. +3. Check secrets, outbound calls, and user-controlled remote targets. +4. Check output exposure and error leakage. +5. Check negative-path tests for forbidden or invalid behavior. + +Response format: +- findings first, ordered by severity +- then assumptions or open questions +- then a short hardening summary + +Rules: +- Tie each finding to a concrete code path. +- Prefer actionable security findings over generic warnings. diff --git a/.claude/skills/verify-quality/SKILL.md b/.claude/skills/verify-quality/SKILL.md index 2f3e987..557dc09 100644 --- a/.claude/skills/verify-quality/SKILL.md +++ b/.claude/skills/verify-quality/SKILL.md @@ -1,5 +1,39 @@ +--- +name: verify-quality +description: Use this skill when the user asks to run checks, validate a change, see whether code is ready, or investigate lint, analysis, test, or security-gate results in a Symfony/API Platform project. +--- + # Verify Quality -- Run the narrowest relevant tests first. -- Then run broader checks if the change crosses boundaries. -- Include native/manual verification notes when automation is not enough. +Use this skill to run the repository's real quality gates in a predictable order. + +Read first: +- `AGENTS.md` +- `.claude/rules/testing.md` + +Read as needed: +- `Makefile` +- `composer.json` + +Workflow: +1. Discover the canonical commands exposed by the repository. +2. Prefer project wrappers such as `make` targets over raw vendor binaries. +3. Run the narrowest relevant tests first, then broader required checks. +4. Report failures with the command, affected area, and smallest likely fix direction. + +Typical order: +1. `make quality` (runs lint, analyse, rector together) +2. `make tests-unit` +3. `make tests-integration` +4. `make tests` +5. `make tests-api` +6. `make security` + +For targeted runs only (skip if running `make quality`): +- `make lint` +- `make analyse` +- `make rector` + +Rules: +- Do not invent substitute commands silently. +- If endpoint behavior changed, do not stop at unit tests only. diff --git a/WORKFLOW.md b/WORKFLOW.md index c855ccc..3ecdf68 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -36,6 +36,16 @@ Le comportement idéal de Fall Guardian est le suivant : 9. L'escalade soumet l'alerte au backend, qui devient l'unique propriétaire de la notification des aidants liés au profil protégé, avec position GPS si disponible. 10. La direction produit cible est une application dédiée pour les aidants, alimentée par des notifications push backend-owned. 11. Android peut conserver un envoi SMS local uniquement comme fallback explicite, pas comme mécanisme principal du produit. + +### Pourquoi les notifications push plutôt que le SMS + +Trois raisons cumulatives ont conduit à ce choix : + +**Raison économique** : les passerelles SMS tierces (Twilio, etc.) sont payantes à l'usage. Chaque alerte envoyée a un coût direct. Les notifications push FCM (Android) et APNs (iOS) sont gratuites, quel que soit le volume. + +**Raison technique iOS** : Apple ne permet pas à une application d'envoyer un SMS en silence. Sur iOS, `flutter_sms` ouvre obligatoirement la feuille de composition Messages native — l'utilisateur doit confirmer l'envoi manuellement. Si la personne est à terre ou inconsciente, personne ne confirme. Le SMS ne part jamais. C'est un échec silencieux dans le cas exactement prévu par le produit. + +**Raison produit** : le SMS est unidirectionnel et sans état. Il n't y a aucune traçabilité (livré ? lu ? acquitté ?), aucune coordination entre plusieurs aidants, et aucune surface pour une application dédiée. Les notifications push permettent de piloter une vraie expérience aidant : écran d'alerte active, acquittement, historique, arrêt des relances quand quelqu'un répond. 12. Si le backend n'est pas joignable ou refuse l'alerte, l'application doit l'indiquer clairement et enregistrer l'échec au lieu de signaler un faux succès. 13. Les réglages de sensibilité modifiés sur le téléphone sont appliqués immédiatement sur la montre, ou mis en file d'attente si la montre est hors ligne. 14. Les permissions critiques ne doivent jamais échouer silencieusement. From 9802f869446a4462c21f250cd3779655caa14495 Mon Sep 17 00:00:00 2001 From: Thomas Laure Date: Sun, 12 Apr 2026 21:29:19 +0200 Subject: [PATCH 2/7] feat(backend): apply hexagonal architecture and add caregiver + push Restructures src/ to Domain/Application/Infrastructure/UI layers following ports-and-adapters. Adds the full caregiver model (CaregiverLink, CaregiverInvite, CaregiverPushToken, AlertAcknowledgement, PushAttempt) with invite-code linking, FCM push gateway, and a SendFallAlertPushMessage handler that dispatches alongside the existing SMS path. Fixes the dd( blacklist false positive on ->add( and removes a non-existent Rector skip rule. Co-Authored-By: Claude Sonnet 4.6 --- backend/composer.json | 1 + backend/composer.lock | 186 +++++++++++++++++- backend/config/bundles.php | 1 + backend/config/packages/security.yaml | 2 +- backend/config/reference.php | 2 +- backend/config/services.yaml | 32 ++- backend/docker-compose.yml | 8 + backend/grumphp.yml | 3 +- backend/migrations/Version20260409120000.php | 3 + backend/migrations/Version20260412120000.php | 68 +++++++ backend/rector.php | 2 - .../Alert/DTO}/CancelFallAlertInput.php | 6 +- .../Alert/DTO}/CreateFallAlertInput.php | 6 +- .../Alert/DTO}/FallAlertView.php | 4 +- .../Handler}/AlertIngestionService.php | 26 ++- .../Handler}/SendFallAlertMessageHandler.php | 32 +-- .../SendFallAlertPushMessageHandler.php | 79 ++++++++ .../Caregiver/DTO/AcceptInviteInput.php | 22 +++ .../Caregiver/DTO/AcknowledgeAlertInput.php | 22 +++ .../Caregiver/DTO/CaregiverAlertView.php | 43 ++++ .../Caregiver/DTO/CreateInviteOutput.php | 35 ++++ .../Caregiver/DTO/RegisterPushTokenInput.php | 24 +++ .../Caregiver/Handler/InviteService.php | 110 +++++++++++ .../Contact/DTO}/ContactInput.php | 2 +- .../Contact/DTO}/ReplaceContactsInput.php | 8 +- .../Contact/DTO}/ReplaceContactsOutput.php | 2 +- .../Handler}/AlertMessageBuilder.php | 2 +- .../Handler}/ContactCryptoService.php | 8 +- .../Handler}/ContactSyncService.php | 14 +- .../Handler}/PhoneNumberNormalizer.php | 6 +- .../Device/DTO}/DeviceRegistrationInput.php | 10 +- .../Device/DTO}/DeviceRegistrationOutput.php | 2 +- .../Handler}/DeviceRegistrationService.php | 19 +- backend/src/Controller/.gitignore | 0 ...lertAcknowledgementRepositoryInterface.php | 16 ++ .../Port/FallAlertRepositoryInterface.php | 20 ++ .../Port/PushAttemptRepositoryInterface.php | 12 ++ .../Port/SmsAttemptRepositoryInterface.php | 14 ++ .../CaregiverInviteRepositoryInterface.php | 14 ++ .../Port/CaregiverLinkRepositoryInterface.php | 21 ++ .../CaregiverPushTokenRepositoryInterface.php | 15 ++ .../EmergencyContactRepositoryInterface.php | 16 ++ .../Device/Port/DeviceRepositoryInterface.php | 14 ++ .../Domain/Push/Port/PushGatewayInterface.php | 13 ++ .../Sms/Port/SmsGatewayInterface.php} | 4 +- backend/src/Entity/AlertAcknowledgement.php | 53 +++++ backend/src/Entity/CaregiverInvite.php | 81 ++++++++ backend/src/Entity/CaregiverLink.php | 83 ++++++++ backend/src/Entity/CaregiverPushToken.php | 58 ++++++ backend/src/Entity/Device.php | 60 ++++-- backend/src/Entity/EmergencyContact.php | 48 ++--- backend/src/Entity/FallAlert.php | 70 ++++--- backend/src/Entity/PushAttempt.php | 82 ++++++++ backend/src/Entity/SmsAttempt.php | 29 +-- backend/src/Enum/CaregiverLinkStatus.php | 12 ++ backend/src/Enum/DeviceType.php | 11 ++ backend/src/Enum/FallAlertStatus.php | 1 + backend/src/Enum/PushAttemptStatus.php | 12 ++ .../Http}/Security/CurrentDeviceProvider.php | 6 +- .../Http}/Security/DeviceApiUser.php | 6 +- .../Security/DeviceTokenAuthenticator.php | 10 +- .../Http}/Security/DeviceTokenHasher.php | 2 +- ...DoctrineAlertAcknowledgementRepository.php | 40 ++++ .../DoctrineCaregiverInviteRepository.php | 43 ++++ .../DoctrineCaregiverLinkRepository.php | 73 +++++++ .../DoctrineCaregiverPushTokenRepository.php | 36 ++++ .../Persistence/DoctrineDeviceRepository.php} | 11 +- .../DoctrineEmergencyContactRepository.php} | 5 +- .../DoctrineFallAlertRepository.php | 58 ++++++ .../DoctrinePushAttemptRepository.php | 27 +++ .../DoctrineSmsAttemptRepository.php} | 11 +- .../Push/DelegatingPushGateway.php | 39 ++++ .../Infrastructure/Push/FakePushGateway.php | 43 ++++ .../Infrastructure/Push/FcmPushGateway.php | 164 +++++++++++++++ .../Sms/DelegatingSmsGateway.php | 12 +- .../src/Infrastructure/Sms/FakeSmsGateway.php | 6 +- .../src/Infrastructure/Sms/FakeSmsStore.php | 17 +- .../Infrastructure/Sms/TwilioSmsGateway.php | 15 +- .../src/Message/SendFallAlertPushMessage.php | 12 ++ backend/src/Repository/.gitignore | 0 .../src/Repository/FallAlertRepository.php | 29 --- .../Controller/DebugFakeSmsController.php | 8 +- .../{ => UI}/Controller/HealthController.php | 2 +- .../Controller/TwilioWebhookController.php | 14 +- .../src/UI/State/AcceptInviteProcessor.php | 49 +++++ .../UI/State/AcknowledgeAlertProcessor.php | 65 ++++++ .../State/CancelFallAlertProcessor.php | 18 +- .../src/UI/State/CaregiverAlertsProvider.php | 52 +++++ .../State/CreateFallAlertProcessor.php | 16 +- .../src/UI/State/CreateInviteProcessor.php | 38 ++++ .../State/DeviceRegistrationProcessor.php | 19 +- .../src/{ => UI}/State/FallAlertProvider.php | 16 +- .../UI/State/RegisterPushTokenProcessor.php | 44 +++++ .../State/ReplaceContactsProcessor.php | 20 +- 94 files changed, 2269 insertions(+), 306 deletions(-) create mode 100644 backend/migrations/Version20260412120000.php rename backend/src/{Api => Application/Alert/DTO}/CancelFallAlertInput.php (83%) rename backend/src/{Api => Application/Alert/DTO}/CreateFallAlertInput.php (90%) rename backend/src/{Api => Application/Alert/DTO}/FallAlertView.php (92%) rename backend/src/Application/{ => Alert/Handler}/AlertIngestionService.php (67%) rename backend/src/{MessageHandler => Application/Alert/Handler}/SendFallAlertMessageHandler.php (64%) create mode 100644 backend/src/Application/Alert/Handler/SendFallAlertPushMessageHandler.php create mode 100644 backend/src/Application/Caregiver/DTO/AcceptInviteInput.php create mode 100644 backend/src/Application/Caregiver/DTO/AcknowledgeAlertInput.php create mode 100644 backend/src/Application/Caregiver/DTO/CaregiverAlertView.php create mode 100644 backend/src/Application/Caregiver/DTO/CreateInviteOutput.php create mode 100644 backend/src/Application/Caregiver/DTO/RegisterPushTokenInput.php create mode 100644 backend/src/Application/Caregiver/Handler/InviteService.php rename backend/src/{Dto => Application/Contact/DTO}/ContactInput.php (89%) rename backend/src/{Api => Application/Contact/DTO}/ReplaceContactsInput.php (82%) rename backend/src/{Dto => Application/Contact/DTO}/ReplaceContactsOutput.php (79%) rename backend/src/Application/{ => Contact/Handler}/AlertMessageBuilder.php (95%) rename backend/src/Application/{ => Contact/Handler}/ContactCryptoService.php (92%) rename backend/src/Application/{ => Contact/Handler}/ContactSyncService.php (76%) rename backend/src/Application/{ => Contact/Handler}/PhoneNumberNormalizer.php (86%) rename backend/src/{Api => Application/Device/DTO}/DeviceRegistrationInput.php (75%) rename backend/src/{Dto => Application/Device/DTO}/DeviceRegistrationOutput.php (83%) rename backend/src/Application/{ => Device/Handler}/DeviceRegistrationService.php (54%) delete mode 100644 backend/src/Controller/.gitignore create mode 100644 backend/src/Domain/Alert/Port/AlertAcknowledgementRepositoryInterface.php create mode 100644 backend/src/Domain/Alert/Port/FallAlertRepositoryInterface.php create mode 100644 backend/src/Domain/Alert/Port/PushAttemptRepositoryInterface.php create mode 100644 backend/src/Domain/Alert/Port/SmsAttemptRepositoryInterface.php create mode 100644 backend/src/Domain/Caregiver/Port/CaregiverInviteRepositoryInterface.php create mode 100644 backend/src/Domain/Caregiver/Port/CaregiverLinkRepositoryInterface.php create mode 100644 backend/src/Domain/Caregiver/Port/CaregiverPushTokenRepositoryInterface.php create mode 100644 backend/src/Domain/Contact/Port/EmergencyContactRepositoryInterface.php create mode 100644 backend/src/Domain/Device/Port/DeviceRepositoryInterface.php create mode 100644 backend/src/Domain/Push/Port/PushGatewayInterface.php rename backend/src/{Application/SmsGateway.php => Domain/Sms/Port/SmsGatewayInterface.php} (77%) create mode 100644 backend/src/Entity/AlertAcknowledgement.php create mode 100644 backend/src/Entity/CaregiverInvite.php create mode 100644 backend/src/Entity/CaregiverLink.php create mode 100644 backend/src/Entity/CaregiverPushToken.php create mode 100644 backend/src/Entity/PushAttempt.php create mode 100644 backend/src/Enum/CaregiverLinkStatus.php create mode 100644 backend/src/Enum/DeviceType.php create mode 100644 backend/src/Enum/PushAttemptStatus.php rename backend/src/{ => Infrastructure/Http}/Security/CurrentDeviceProvider.php (74%) rename backend/src/{ => Infrastructure/Http}/Security/DeviceApiUser.php (75%) rename backend/src/{ => Infrastructure/Http}/Security/DeviceTokenAuthenticator.php (88%) rename backend/src/{ => Infrastructure/Http}/Security/DeviceTokenHasher.php (86%) create mode 100644 backend/src/Infrastructure/Persistence/DoctrineAlertAcknowledgementRepository.php create mode 100644 backend/src/Infrastructure/Persistence/DoctrineCaregiverInviteRepository.php create mode 100644 backend/src/Infrastructure/Persistence/DoctrineCaregiverLinkRepository.php create mode 100644 backend/src/Infrastructure/Persistence/DoctrineCaregiverPushTokenRepository.php rename backend/src/{Repository/DeviceRepository.php => Infrastructure/Persistence/DoctrineDeviceRepository.php} (60%) rename backend/src/{Repository/EmergencyContactRepository.php => Infrastructure/Persistence/DoctrineEmergencyContactRepository.php} (82%) create mode 100644 backend/src/Infrastructure/Persistence/DoctrineFallAlertRepository.php create mode 100644 backend/src/Infrastructure/Persistence/DoctrinePushAttemptRepository.php rename backend/src/{Repository/SmsAttemptRepository.php => Infrastructure/Persistence/DoctrineSmsAttemptRepository.php} (59%) create mode 100644 backend/src/Infrastructure/Push/DelegatingPushGateway.php create mode 100644 backend/src/Infrastructure/Push/FakePushGateway.php create mode 100644 backend/src/Infrastructure/Push/FcmPushGateway.php create mode 100644 backend/src/Message/SendFallAlertPushMessage.php delete mode 100644 backend/src/Repository/.gitignore delete mode 100644 backend/src/Repository/FallAlertRepository.php rename backend/src/{ => UI}/Controller/DebugFakeSmsController.php (81%) rename backend/src/{ => UI}/Controller/HealthController.php (93%) rename backend/src/{ => UI}/Controller/TwilioWebhookController.php (73%) create mode 100644 backend/src/UI/State/AcceptInviteProcessor.php create mode 100644 backend/src/UI/State/AcknowledgeAlertProcessor.php rename backend/src/{ => UI}/State/CancelFallAlertProcessor.php (66%) create mode 100644 backend/src/UI/State/CaregiverAlertsProvider.php rename backend/src/{ => UI}/State/CreateFallAlertProcessor.php (65%) create mode 100644 backend/src/UI/State/CreateInviteProcessor.php rename backend/src/{ => UI}/State/DeviceRegistrationProcessor.php (54%) rename backend/src/{ => UI}/State/FallAlertProvider.php (67%) create mode 100644 backend/src/UI/State/RegisterPushTokenProcessor.php rename backend/src/{ => UI}/State/ReplaceContactsProcessor.php (76%) diff --git a/backend/composer.json b/backend/composer.json index 4153d2e..71c129c 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -20,6 +20,7 @@ "symfony/dotenv": "7.4.*", "symfony/flex": "^2", "symfony/framework-bundle": "7.4.*", + "symfony/http-client": "7.4.*", "symfony/messenger": "7.4.*", "symfony/property-access": "7.4.*", "symfony/property-info": "7.4.*", diff --git a/backend/composer.lock b/backend/composer.lock index e059c8b..62e18c7 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "75cd08ffb2a108462d893ba8428a229b", + "content-hash": "13502b99d13627b63fd7112a6ec4cc23", "packages": [ { "name": "api-platform/doctrine-common", @@ -4333,6 +4333,185 @@ ], "time": "2026-03-30T12:55:43+00:00" }, + { + "name": "symfony/http-client", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "01933e626c3de76bea1e22641e205e78f6a34342" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/01933e626c3de76bea1e22641e205e78f6a34342", + "reference": "01933e626c3de76bea1e22641e205e78f6a34342", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<2.5", + "amphp/socket": "<1.1", + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.4" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^4.2.1|^5.0", + "amphp/http-tunnel": "^1.0|^2.0", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/amphp-http-client-meta": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T12:55:43+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "75d7043853a42837e68111812f4d964b01e5101c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", + "reference": "75d7043853a42837e68111812f4d964b01e5101c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-29T11:18:49+00:00" + }, { "name": "symfony/http-foundation", "version": "v7.4.8", @@ -14062,9 +14241,10 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": ">=8.2", + "php": ">=8.5.0", "ext-ctype": "*", - "ext-iconv": "*" + "ext-iconv": "*", + "ext-pdo_pgsql": "*" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/backend/config/bundles.php b/backend/config/bundles.php index cdda7c1..bb71a34 100644 --- a/backend/config/bundles.php +++ b/backend/config/bundles.php @@ -12,4 +12,5 @@ Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], Symfony\Bundle\TwigBundle\TwigBundle::class => ['dev' => true, 'test' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], + FriendsOfBehat\SymfonyExtension\Bundle\FriendsOfBehatSymfonyExtensionBundle::class => ['dev' => true, 'test' => true], ]; diff --git a/backend/config/packages/security.yaml b/backend/config/packages/security.yaml index 609ecc5..053aa83 100644 --- a/backend/config/packages/security.yaml +++ b/backend/config/packages/security.yaml @@ -11,7 +11,7 @@ security: stateless: true provider: devices_in_memory custom_authenticators: - - App\Security\DeviceTokenAuthenticator + - App\Infrastructure\Http\Security\DeviceTokenAuthenticator access_control: - { path: ^/health$, roles: PUBLIC_ACCESS } diff --git a/backend/config/reference.php b/backend/config/reference.php index b567b4b..308bff1 100644 --- a/backend/config/reference.php +++ b/backend/config/reference.php @@ -472,7 +472,7 @@ * }, * disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true * http_client?: bool|array{ // HTTP Client configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * max_host_connections?: int|Param, // The maximum number of connections to a single host. * default_options?: array{ * headers?: array, diff --git a/backend/config/services.yaml b/backend/config/services.yaml index c88daef..4a6b472 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -15,6 +15,9 @@ parameters: app.twilio.account_sid: '%env(string:SMS_PROVIDER_TWILIO_ACCOUNT_SID)%' app.twilio.auth_token: '%env(string:SMS_PROVIDER_TWILIO_AUTH_TOKEN)%' app.twilio.from: '%env(string:SMS_PROVIDER_TWILIO_FROM)%' + app.push_provider: '%env(string:PUSH_PROVIDER)%' + app.fcm.project_id: '%env(string:FCM_PROJECT_ID)%' + app.fcm.service_account_json: '%env(string:FCM_SERVICE_ACCOUNT_JSON)%' services: # default configuration for services in *this* file @@ -27,11 +30,11 @@ services: App\: resource: '../src/' - App\Application\PhoneNumberNormalizer: + App\Application\Contact\Handler\PhoneNumberNormalizer: arguments: $defaultCountryCode: '%app.default_country_code%' - App\Application\ContactCryptoService: + App\Application\Contact\Handler\ContactCryptoService: arguments: $encryptionSecret: '%app.contact_encryption_key%' $hashSecret: '%app.contact_hash_key%' @@ -51,8 +54,29 @@ services: arguments: $provider: '%app.sms_provider%' - App\Controller\DebugFakeSmsController: + App\UI\Controller\DebugFakeSmsController: arguments: $appEnv: '%kernel.environment%' - App\Application\SmsGateway: '@App\Infrastructure\Sms\DelegatingSmsGateway' + App\Domain\Sms\Port\SmsGatewayInterface: '@App\Infrastructure\Sms\DelegatingSmsGateway' + + App\Domain\Alert\Port\FallAlertRepositoryInterface: '@App\Infrastructure\Persistence\DoctrineFallAlertRepository' + App\Domain\Alert\Port\SmsAttemptRepositoryInterface: '@App\Infrastructure\Persistence\DoctrineSmsAttemptRepository' + App\Domain\Alert\Port\PushAttemptRepositoryInterface: '@App\Infrastructure\Persistence\DoctrinePushAttemptRepository' + App\Domain\Alert\Port\AlertAcknowledgementRepositoryInterface: '@App\Infrastructure\Persistence\DoctrineAlertAcknowledgementRepository' + App\Domain\Device\Port\DeviceRepositoryInterface: '@App\Infrastructure\Persistence\DoctrineDeviceRepository' + App\Domain\Caregiver\Port\CaregiverInviteRepositoryInterface: '@App\Infrastructure\Persistence\DoctrineCaregiverInviteRepository' + App\Domain\Caregiver\Port\CaregiverLinkRepositoryInterface: '@App\Infrastructure\Persistence\DoctrineCaregiverLinkRepository' + App\Domain\Caregiver\Port\CaregiverPushTokenRepositoryInterface: '@App\Infrastructure\Persistence\DoctrineCaregiverPushTokenRepository' + App\Domain\Contact\Port\EmergencyContactRepositoryInterface: '@App\Infrastructure\Persistence\DoctrineEmergencyContactRepository' + + App\Infrastructure\Push\FcmPushGateway: + arguments: + $projectId: '%app.fcm.project_id%' + $serviceAccountJson: '%app.fcm.service_account_json%' + + App\Infrastructure\Push\DelegatingPushGateway: + arguments: + $provider: '%app.push_provider%' + + App\Domain\Push\Port\PushGatewayInterface: '@App\Infrastructure\Push\DelegatingPushGateway' diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index b5dc35a..977dcf8 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -26,9 +26,13 @@ services: CONTACT_ENCRYPTION_KEY: ${CONTACT_ENCRYPTION_KEY:-change-me-encryption} CONTACT_HASH_KEY: ${CONTACT_HASH_KEY:-change-me-hash} APP_DEFAULT_COUNTRY_CODE: ${APP_DEFAULT_COUNTRY_CODE:-+33} + SMS_PROVIDER: ${SMS_PROVIDER:-fake} SMS_PROVIDER_TWILIO_ACCOUNT_SID: ${SMS_PROVIDER_TWILIO_ACCOUNT_SID:-} SMS_PROVIDER_TWILIO_AUTH_TOKEN: ${SMS_PROVIDER_TWILIO_AUTH_TOKEN:-} SMS_PROVIDER_TWILIO_FROM: ${SMS_PROVIDER_TWILIO_FROM:-} + PUSH_PROVIDER: ${PUSH_PROVIDER:-fake} + FCM_PROJECT_ID: ${FCM_PROJECT_ID:-} + FCM_SERVICE_ACCOUNT_JSON: ${FCM_SERVICE_ACCOUNT_JSON:-} FRANKENPHP_CONFIG: worker ./public/index.php SERVER_NAME: :80 depends_on: @@ -59,9 +63,13 @@ services: CONTACT_ENCRYPTION_KEY: ${CONTACT_ENCRYPTION_KEY:-change-me-encryption} CONTACT_HASH_KEY: ${CONTACT_HASH_KEY:-change-me-hash} APP_DEFAULT_COUNTRY_CODE: ${APP_DEFAULT_COUNTRY_CODE:-+33} + SMS_PROVIDER: ${SMS_PROVIDER:-fake} SMS_PROVIDER_TWILIO_ACCOUNT_SID: ${SMS_PROVIDER_TWILIO_ACCOUNT_SID:-} SMS_PROVIDER_TWILIO_AUTH_TOKEN: ${SMS_PROVIDER_TWILIO_AUTH_TOKEN:-} SMS_PROVIDER_TWILIO_FROM: ${SMS_PROVIDER_TWILIO_FROM:-} + PUSH_PROVIDER: ${PUSH_PROVIDER:-fake} + FCM_PROJECT_ID: ${FCM_PROJECT_ID:-} + FCM_SERVICE_ACCOUNT_JSON: ${FCM_SERVICE_ACCOUNT_JSON:-} depends_on: postgres: condition: service_healthy diff --git a/backend/grumphp.yml b/backend/grumphp.yml index eac740f..5d0d755 100644 --- a/backend/grumphp.yml +++ b/backend/grumphp.yml @@ -27,7 +27,8 @@ grumphp: - "exit;" - "exit();" - "print_r(" - - "dd(" + - "[^a-zA-Z_]dd(" + - "^dd(" regexp_type: G yamllint: diff --git a/backend/migrations/Version20260409120000.php b/backend/migrations/Version20260409120000.php index fe52554..36f1d62 100644 --- a/backend/migrations/Version20260409120000.php +++ b/backend/migrations/Version20260409120000.php @@ -6,9 +6,11 @@ use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; +use Override; final class Version20260409120000 extends AbstractMigration { + #[Override] public function getDescription(): string { return 'Creates device, contact, alert, and SMS attempt tables'; @@ -35,6 +37,7 @@ public function up(Schema $schema): void $this->addSql('ALTER TABLE sms_attempts ADD CONSTRAINT FK_SMS_CONTACT FOREIGN KEY (contact_id) REFERENCES emergency_contacts (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); } + #[Override] public function down(Schema $schema): void { $this->addSql('DROP TABLE sms_attempts'); diff --git a/backend/migrations/Version20260412120000.php b/backend/migrations/Version20260412120000.php new file mode 100644 index 0000000..c33e83a --- /dev/null +++ b/backend/migrations/Version20260412120000.php @@ -0,0 +1,68 @@ +addSql("ALTER TABLE devices ADD device_type VARCHAR(32) NOT NULL DEFAULT 'protected_person'"); + + // Caregiver invites — short-lived codes emitted by protected-person devices + $this->addSql('CREATE TABLE caregiver_invites (id UUID NOT NULL, device_id UUID NOT NULL, code VARCHAR(8) NOT NULL, expires_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, used_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX uniq_caregiver_invites_code ON caregiver_invites (code)'); + $this->addSql('CREATE INDEX idx_caregiver_invites_device ON caregiver_invites (device_id)'); + $this->addSql('ALTER TABLE caregiver_invites ADD CONSTRAINT FK_CAREGIVER_INVITES_DEVICE FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + + // Caregiver links — active associations between a protected device and a caregiver device + $this->addSql('CREATE TABLE caregiver_links (id UUID NOT NULL, protected_device_id UUID NOT NULL, caregiver_device_id UUID NOT NULL, status VARCHAR(32) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX uniq_caregiver_links_pair ON caregiver_links (protected_device_id, caregiver_device_id)'); + $this->addSql('CREATE INDEX idx_caregiver_links_protected ON caregiver_links (protected_device_id)'); + $this->addSql('CREATE INDEX idx_caregiver_links_caregiver ON caregiver_links (caregiver_device_id)'); + $this->addSql('ALTER TABLE caregiver_links ADD CONSTRAINT FK_CAREGIVER_LINKS_PROTECTED FOREIGN KEY (protected_device_id) REFERENCES devices (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE caregiver_links ADD CONSTRAINT FK_CAREGIVER_LINKS_CAREGIVER FOREIGN KEY (caregiver_device_id) REFERENCES devices (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + + // Caregiver push tokens — one FCM token per caregiver device + $this->addSql('CREATE TABLE caregiver_push_tokens (id UUID NOT NULL, device_id UUID NOT NULL, fcm_token TEXT NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX uniq_push_tokens_device ON caregiver_push_tokens (device_id)'); + $this->addSql('ALTER TABLE caregiver_push_tokens ADD CONSTRAINT FK_PUSH_TOKENS_DEVICE FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + + // Push attempts — one row per caregiver notified per alert + $this->addSql('CREATE TABLE push_attempts (id UUID NOT NULL, fall_alert_id UUID NOT NULL, caregiver_device_id UUID NOT NULL, provider VARCHAR(32) NOT NULL, provider_message_id VARCHAR(255) DEFAULT NULL, status VARCHAR(32) NOT NULL, error_code VARCHAR(255) DEFAULT NULL, error_message TEXT DEFAULT NULL, retry_count INT NOT NULL, queued_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, sent_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX idx_push_attempts_alert ON push_attempts (fall_alert_id)'); + $this->addSql('CREATE INDEX idx_push_attempts_caregiver ON push_attempts (caregiver_device_id)'); + $this->addSql('ALTER TABLE push_attempts ADD CONSTRAINT FK_PUSH_ATTEMPTS_ALERT FOREIGN KEY (fall_alert_id) REFERENCES fall_alerts (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE push_attempts ADD CONSTRAINT FK_PUSH_ATTEMPTS_CAREGIVER FOREIGN KEY (caregiver_device_id) REFERENCES devices (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + + // Alert acknowledgements — caregiver confirmation that alert was received + $this->addSql('CREATE TABLE alert_acknowledgements (id UUID NOT NULL, fall_alert_id UUID NOT NULL, caregiver_device_id UUID NOT NULL, acknowledged_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX uniq_ack_alert_caregiver ON alert_acknowledgements (fall_alert_id, caregiver_device_id)'); + $this->addSql('CREATE INDEX idx_ack_alert ON alert_acknowledgements (fall_alert_id)'); + $this->addSql('ALTER TABLE alert_acknowledgements ADD CONSTRAINT FK_ACK_ALERT FOREIGN KEY (fall_alert_id) REFERENCES fall_alerts (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE alert_acknowledgements ADD CONSTRAINT FK_ACK_CAREGIVER FOREIGN KEY (caregiver_device_id) REFERENCES devices (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + #[Override] + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE alert_acknowledgements'); + $this->addSql('DROP TABLE push_attempts'); + $this->addSql('DROP TABLE caregiver_push_tokens'); + $this->addSql('DROP TABLE caregiver_links'); + $this->addSql('DROP TABLE caregiver_invites'); + $this->addSql('ALTER TABLE devices DROP COLUMN device_type'); + } +} diff --git a/backend/rector.php b/backend/rector.php index 55e497c..e34fdd6 100644 --- a/backend/rector.php +++ b/backend/rector.php @@ -4,7 +4,6 @@ use Rector\Config\RectorConfig; use Rector\Doctrine\Set\DoctrineSetList; -use Rector\Php85\Rector\Class_\AddOverrideAttributeToOverriddenMethodsRector; use Rector\PHPUnit\Set\PHPUnitSetList; use Rector\Symfony\Set\SymfonySetList; @@ -17,7 +16,6 @@ __DIR__ . '/var', __DIR__ . '/vendor', __DIR__ . '/config/reference.php', - AddOverrideAttributeToOverriddenMethodsRector::class, ]) ->withPhpSets(php85: true) ->withPreparedSets( diff --git a/backend/src/Api/CancelFallAlertInput.php b/backend/src/Application/Alert/DTO/CancelFallAlertInput.php similarity index 83% rename from backend/src/Api/CancelFallAlertInput.php rename to backend/src/Application/Alert/DTO/CancelFallAlertInput.php index d019881..d6a3674 100644 --- a/backend/src/Api/CancelFallAlertInput.php +++ b/backend/src/Application/Alert/DTO/CancelFallAlertInput.php @@ -2,19 +2,19 @@ declare(strict_types=1); -namespace App\Api; +namespace App\Application\Alert\DTO; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Post; -use App\State\CancelFallAlertProcessor; +use App\UI\State\CancelFallAlertProcessor; #[ApiResource(operations: [ new Post( uriTemplate: '/api/v1/fall-alerts/{clientAlertId}/cancel', output: FallAlertView::class, - processor: CancelFallAlertProcessor::class, read: false, deserialize: false, + processor: CancelFallAlertProcessor::class, ), ])] final class CancelFallAlertInput diff --git a/backend/src/Api/CreateFallAlertInput.php b/backend/src/Application/Alert/DTO/CreateFallAlertInput.php similarity index 90% rename from backend/src/Api/CreateFallAlertInput.php rename to backend/src/Application/Alert/DTO/CreateFallAlertInput.php index c532bf2..585cbe6 100644 --- a/backend/src/Api/CreateFallAlertInput.php +++ b/backend/src/Application/Alert/DTO/CreateFallAlertInput.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace App\Api; +namespace App\Application\Alert\DTO; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Post; -use App\State\CreateFallAlertProcessor; +use App\UI\State\CreateFallAlertProcessor; use DateTimeImmutable; use Symfony\Component\Validator\Constraints as Assert; @@ -14,8 +14,8 @@ new Post( uriTemplate: '/api/v1/fall-alerts', output: FallAlertView::class, - processor: CreateFallAlertProcessor::class, read: false, + processor: CreateFallAlertProcessor::class, ), ])] final class CreateFallAlertInput diff --git a/backend/src/Api/FallAlertView.php b/backend/src/Application/Alert/DTO/FallAlertView.php similarity index 92% rename from backend/src/Api/FallAlertView.php rename to backend/src/Application/Alert/DTO/FallAlertView.php index fba5671..e885723 100644 --- a/backend/src/Api/FallAlertView.php +++ b/backend/src/Application/Alert/DTO/FallAlertView.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace App\Api; +namespace App\Application\Alert\DTO; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use App\Entity\FallAlert; -use App\State\FallAlertProvider; +use App\UI\State\FallAlertProvider; use const DATE_ATOM; diff --git a/backend/src/Application/AlertIngestionService.php b/backend/src/Application/Alert/Handler/AlertIngestionService.php similarity index 67% rename from backend/src/Application/AlertIngestionService.php rename to backend/src/Application/Alert/Handler/AlertIngestionService.php index f79bcef..da03417 100644 --- a/backend/src/Application/AlertIngestionService.php +++ b/backend/src/Application/Alert/Handler/AlertIngestionService.php @@ -2,23 +2,21 @@ declare(strict_types=1); -namespace App\Application; +namespace App\Application\Alert\Handler; +use App\Domain\Alert\Port\FallAlertRepositoryInterface; use App\Entity\Device; use App\Entity\FallAlert; use App\Message\SendFallAlertMessage; -use App\Repository\FallAlertRepository; +use App\Message\SendFallAlertPushMessage; use DateTimeImmutable; -use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Messenger\MessageBusInterface; -use Symfony\Component\Uid\Uuid; -final class AlertIngestionService +final readonly class AlertIngestionService { public function __construct( - private readonly FallAlertRepository $fallAlertRepository, - private readonly EntityManagerInterface $entityManager, - private readonly MessageBusInterface $messageBus, + private FallAlertRepositoryInterface $fallAlertRepository, + private MessageBusInterface $messageBus, ) { } @@ -26,15 +24,15 @@ public function createAlert(Device $device, string $clientAlertId, DateTimeImmut { $existing = $this->fallAlertRepository->findOneByDeviceAndClientAlertId($device, $clientAlertId); - if (null !== $existing) { + if ($existing instanceof FallAlert) { return $existing; } $alert = new FallAlert($device, $clientAlertId, $fallTimestamp, $locale, $latitude, $longitude); - $this->entityManager->persist($alert); - $this->entityManager->flush(); + $this->fallAlertRepository->save($alert); $this->messageBus->dispatch(new SendFallAlertMessage($alert->getId()->toRfc4122())); + $this->messageBus->dispatch(new SendFallAlertPushMessage($alert->getId()->toRfc4122())); return $alert; } @@ -43,19 +41,19 @@ public function cancelAlert(Device $device, string $clientAlertId): ?FallAlert { $alert = $this->fallAlertRepository->findOneByDeviceAndClientAlertId($device, $clientAlertId); - if (null === $alert) { + if (!$alert instanceof FallAlert) { return null; } $alert->cancel(); - $this->entityManager->flush(); + $this->fallAlertRepository->save($alert); return $alert; } public function getAlertForDevice(Device $device, string $alertId): ?FallAlert { - $alert = $this->fallAlertRepository->find(Uuid::fromString($alertId)); + $alert = $this->fallAlertRepository->findById($alertId); if (!$alert instanceof FallAlert || !$alert->getDevice()->getId()->equals($device->getId())) { return null; diff --git a/backend/src/MessageHandler/SendFallAlertMessageHandler.php b/backend/src/Application/Alert/Handler/SendFallAlertMessageHandler.php similarity index 64% rename from backend/src/MessageHandler/SendFallAlertMessageHandler.php rename to backend/src/Application/Alert/Handler/SendFallAlertMessageHandler.php index 347bbdd..073b1fe 100644 --- a/backend/src/MessageHandler/SendFallAlertMessageHandler.php +++ b/backend/src/Application/Alert/Handler/SendFallAlertMessageHandler.php @@ -2,40 +2,42 @@ declare(strict_types=1); -namespace App\MessageHandler; +namespace App\Application\Alert\Handler; -use App\Application\AlertMessageBuilder; -use App\Application\ContactCryptoService; -use App\Application\SmsGateway; +use App\Application\Contact\Handler\AlertMessageBuilder; +use App\Application\Contact\Handler\ContactCryptoService; +use App\Domain\Alert\Port\FallAlertRepositoryInterface; +use App\Domain\Contact\Port\EmergencyContactRepositoryInterface; +use App\Domain\Sms\Port\SmsGatewayInterface; use App\Entity\FallAlert; use App\Entity\SmsAttempt; use App\Message\SendFallAlertMessage; -use App\Repository\EmergencyContactRepository; use function count; +use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; -use Symfony\Component\Uid\Uuid; use Throwable; #[AsMessageHandler] -final class SendFallAlertMessageHandler +final readonly class SendFallAlertMessageHandler { public function __construct( - private readonly EntityManagerInterface $entityManager, - private readonly EmergencyContactRepository $contactRepository, - private readonly ContactCryptoService $contactCryptoService, - private readonly SmsGateway $smsGateway, - private readonly AlertMessageBuilder $messageBuilder, + private FallAlertRepositoryInterface $fallAlertRepository, + private EmergencyContactRepositoryInterface $contactRepository, + private ContactCryptoService $contactCryptoService, + private SmsGatewayInterface $smsGateway, + private AlertMessageBuilder $messageBuilder, + private EntityManagerInterface $entityManager, ) { } public function __invoke(SendFallAlertMessage $message): void { - $alert = $this->entityManager->find(FallAlert::class, Uuid::fromString($message->fallAlertId)); + $alert = $this->fallAlertRepository->findById($message->fallAlertId); - if (!$alert instanceof FallAlert || null !== $alert->getCancelledAt()) { + if (!$alert instanceof FallAlert || $alert->getCancelledAt() instanceof DateTimeImmutable) { return; } @@ -45,7 +47,7 @@ public function __invoke(SendFallAlertMessage $message): void if ([] === $contacts) { $alert->markFailed(); - $this->entityManager->flush(); + $this->fallAlertRepository->save($alert); return; } diff --git a/backend/src/Application/Alert/Handler/SendFallAlertPushMessageHandler.php b/backend/src/Application/Alert/Handler/SendFallAlertPushMessageHandler.php new file mode 100644 index 0000000..f24e3b2 --- /dev/null +++ b/backend/src/Application/Alert/Handler/SendFallAlertPushMessageHandler.php @@ -0,0 +1,79 @@ +fallAlertRepository->findById($message->fallAlertId); + + if (!$alert instanceof FallAlert || $alert->getCancelledAt() instanceof DateTimeImmutable) { + return; + } + + $links = $this->caregiverLinkRepository->findActiveByProtectedDevice($alert->getDevice()); + + if ([] === $links) { + return; + } + + $fallTimestamp = $alert->getFallDetectedAt()->format(DateTimeInterface::ATOM); + $provider = $this->pushGateway->getProviderName(); + $sentCount = 0; + + foreach ($links as $link) { + $caregiverDevice = $link->getCaregiverDevice(); + $pushToken = $this->pushTokenRepository->findByDevice($caregiverDevice); + + if (!$pushToken instanceof \App\Entity\CaregiverPushToken) { + continue; + } + + $attempt = new PushAttempt($alert, $caregiverDevice, $provider); + $alert->addPushAttempt($attempt); + $this->entityManager->persist($attempt); + + try { + $result = $this->pushGateway->send( + $pushToken->getFcmToken(), + $alert->getId()->toRfc4122(), + $fallTimestamp, + $alert->getLatitude(), + $alert->getLongitude(), + ); + $attempt->markSent($result['providerMessageId']); + ++$sentCount; + } catch (Throwable $exception) { + $attempt->markFailed((string) $exception->getCode(), $exception->getMessage()); + } + } + + $this->entityManager->flush(); + } +} diff --git a/backend/src/Application/Caregiver/DTO/AcceptInviteInput.php b/backend/src/Application/Caregiver/DTO/AcceptInviteInput.php new file mode 100644 index 0000000..4736d42 --- /dev/null +++ b/backend/src/Application/Caregiver/DTO/AcceptInviteInput.php @@ -0,0 +1,22 @@ +getId()->toRfc4122(), + $alert->getStatus()->value, + $alert->getFallDetectedAt()->format(DateTimeInterface::ATOM), + $alert->getLatitude(), + $alert->getLongitude(), + $acknowledged, + ); + } +} diff --git a/backend/src/Application/Caregiver/DTO/CreateInviteOutput.php b/backend/src/Application/Caregiver/DTO/CreateInviteOutput.php new file mode 100644 index 0000000..21b2a30 --- /dev/null +++ b/backend/src/Application/Caregiver/DTO/CreateInviteOutput.php @@ -0,0 +1,35 @@ +code = $code; + $output->expiresAt = $expiresAt->format(DateTimeInterface::ATOM); + + return $output; + } +} diff --git a/backend/src/Application/Caregiver/DTO/RegisterPushTokenInput.php b/backend/src/Application/Caregiver/DTO/RegisterPushTokenInput.php new file mode 100644 index 0000000..aefe7d0 --- /dev/null +++ b/backend/src/Application/Caregiver/DTO/RegisterPushTokenInput.php @@ -0,0 +1,24 @@ +isCaregiver()) { + throw new DomainException('Only protected-person devices can create invites.'); + } + + $code = strtoupper(substr(bin2hex(random_bytes(4)), 0, self::CODE_LENGTH)); + $expiresAt = new DateTimeImmutable(sprintf('+%d minutes', self::TTL_MINUTES)); + + $invite = new CaregiverInvite($protectedDevice, $code, $expiresAt); + $this->inviteRepository->save($invite); + + return $invite; + } + + public function acceptInvite(string $code, Device $caregiverDevice): CaregiverLink + { + if (!$caregiverDevice->isCaregiver()) { + throw new DomainException('Only caregiver devices can accept invites.'); + } + + $invite = $this->inviteRepository->findActiveByCode($code); + + if (!$invite instanceof CaregiverInvite) { + throw new RuntimeException('Invite not found, expired, or already used.'); + } + + $protectedDevice = $invite->getDevice(); + + $existing = $this->linkRepository->findExistingPair($protectedDevice, $caregiverDevice); + + if ($existing instanceof CaregiverLink) { + if ($existing->getStatus() === CaregiverLinkStatus::Revoked) { + throw new DomainException('This link has been revoked.'); + } + $invite->markUsed(); + $this->inviteRepository->save($invite); + + return $existing; + } + + $link = new CaregiverLink($protectedDevice, $caregiverDevice); + $invite->markUsed(); + + $this->inviteRepository->save($invite); + $this->linkRepository->save($link); + + return $link; + } + + public function registerPushToken(Device $caregiverDevice, string $fcmToken): CaregiverPushToken + { + if (!$caregiverDevice->isCaregiver()) { + throw new DomainException('Only caregiver devices can register push tokens.'); + } + + $token = $this->pushTokenRepository->findByDevice($caregiverDevice); + + if ($token instanceof CaregiverPushToken) { + $token->update($fcmToken); + } else { + $token = new CaregiverPushToken($caregiverDevice, $fcmToken); + } + + $this->pushTokenRepository->save($token); + + return $token; + } +} diff --git a/backend/src/Dto/ContactInput.php b/backend/src/Application/Contact/DTO/ContactInput.php similarity index 89% rename from backend/src/Dto/ContactInput.php rename to backend/src/Application/Contact/DTO/ContactInput.php index 4e711b0..76c4bde 100644 --- a/backend/src/Dto/ContactInput.php +++ b/backend/src/Application/Contact/DTO/ContactInput.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Dto; +namespace App\Application\Contact\DTO; use Symfony\Component\Validator\Constraints as Assert; diff --git a/backend/src/Api/ReplaceContactsInput.php b/backend/src/Application/Contact/DTO/ReplaceContactsInput.php similarity index 82% rename from backend/src/Api/ReplaceContactsInput.php rename to backend/src/Application/Contact/DTO/ReplaceContactsInput.php index 5dcd979..5ca07be 100644 --- a/backend/src/Api/ReplaceContactsInput.php +++ b/backend/src/Application/Contact/DTO/ReplaceContactsInput.php @@ -2,21 +2,19 @@ declare(strict_types=1); -namespace App\Api; +namespace App\Application\Contact\DTO; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Put; -use App\Dto\ContactInput; -use App\Dto\ReplaceContactsOutput; -use App\State\ReplaceContactsProcessor; +use App\UI\State\ReplaceContactsProcessor; use Symfony\Component\Validator\Constraints as Assert; #[ApiResource(operations: [ new Put( uriTemplate: '/api/v1/emergency-contacts', output: ReplaceContactsOutput::class, - processor: ReplaceContactsProcessor::class, read: false, + processor: ReplaceContactsProcessor::class, ), ])] final class ReplaceContactsInput diff --git a/backend/src/Dto/ReplaceContactsOutput.php b/backend/src/Application/Contact/DTO/ReplaceContactsOutput.php similarity index 79% rename from backend/src/Dto/ReplaceContactsOutput.php rename to backend/src/Application/Contact/DTO/ReplaceContactsOutput.php index cfc823f..3a37fa2 100644 --- a/backend/src/Dto/ReplaceContactsOutput.php +++ b/backend/src/Application/Contact/DTO/ReplaceContactsOutput.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Dto; +namespace App\Application\Contact\DTO; final class ReplaceContactsOutput { diff --git a/backend/src/Application/AlertMessageBuilder.php b/backend/src/Application/Contact/Handler/AlertMessageBuilder.php similarity index 95% rename from backend/src/Application/AlertMessageBuilder.php rename to backend/src/Application/Contact/Handler/AlertMessageBuilder.php index cfa768b..cd9976a 100644 --- a/backend/src/Application/AlertMessageBuilder.php +++ b/backend/src/Application/Contact/Handler/AlertMessageBuilder.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Application; +namespace App\Application\Contact\Handler; use App\Entity\FallAlert; diff --git a/backend/src/Application/ContactCryptoService.php b/backend/src/Application/Contact/Handler/ContactCryptoService.php similarity index 92% rename from backend/src/Application/ContactCryptoService.php rename to backend/src/Application/Contact/Handler/ContactCryptoService.php index dfde71f..da86872 100644 --- a/backend/src/Application/ContactCryptoService.php +++ b/backend/src/Application/Contact/Handler/ContactCryptoService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Application; +namespace App\Application\Contact\Handler; use RuntimeException; @@ -11,11 +11,11 @@ use function sprintf; use function strlen; -final class ContactCryptoService +final readonly class ContactCryptoService { - private readonly string $encryptionKey; + private string $encryptionKey; - private readonly string $hashKey; + private string $hashKey; public function __construct(string $encryptionSecret, string $hashSecret) { diff --git a/backend/src/Application/ContactSyncService.php b/backend/src/Application/Contact/Handler/ContactSyncService.php similarity index 76% rename from backend/src/Application/ContactSyncService.php rename to backend/src/Application/Contact/Handler/ContactSyncService.php index 44e4889..8d3f15e 100644 --- a/backend/src/Application/ContactSyncService.php +++ b/backend/src/Application/Contact/Handler/ContactSyncService.php @@ -2,23 +2,23 @@ declare(strict_types=1); -namespace App\Application; +namespace App\Application\Contact\Handler; +use App\Domain\Contact\Port\EmergencyContactRepositoryInterface; use App\Entity\Device; use App\Entity\EmergencyContact; -use App\Repository\EmergencyContactRepository; use function count; use Doctrine\ORM\EntityManagerInterface; -final class ContactSyncService +final readonly class ContactSyncService { public function __construct( - private readonly EmergencyContactRepository $contactRepository, - private readonly PhoneNumberNormalizer $phoneNumberNormalizer, - private readonly ContactCryptoService $contactCryptoService, - private readonly EntityManagerInterface $entityManager, + private EmergencyContactRepositoryInterface $contactRepository, + private PhoneNumberNormalizer $phoneNumberNormalizer, + private ContactCryptoService $contactCryptoService, + private EntityManagerInterface $entityManager, ) { } diff --git a/backend/src/Application/PhoneNumberNormalizer.php b/backend/src/Application/Contact/Handler/PhoneNumberNormalizer.php similarity index 86% rename from backend/src/Application/PhoneNumberNormalizer.php rename to backend/src/Application/Contact/Handler/PhoneNumberNormalizer.php index fc45390..deba0be 100644 --- a/backend/src/Application/PhoneNumberNormalizer.php +++ b/backend/src/Application/Contact/Handler/PhoneNumberNormalizer.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace App\Application; +namespace App\Application\Contact\Handler; use InvalidArgumentException; -final class PhoneNumberNormalizer +final readonly class PhoneNumberNormalizer { - public function __construct(private readonly string $defaultCountryCode) + public function __construct(private string $defaultCountryCode) { } diff --git a/backend/src/Api/DeviceRegistrationInput.php b/backend/src/Application/Device/DTO/DeviceRegistrationInput.php similarity index 75% rename from backend/src/Api/DeviceRegistrationInput.php rename to backend/src/Application/Device/DTO/DeviceRegistrationInput.php index c429ad0..58d2755 100644 --- a/backend/src/Api/DeviceRegistrationInput.php +++ b/backend/src/Application/Device/DTO/DeviceRegistrationInput.php @@ -2,20 +2,19 @@ declare(strict_types=1); -namespace App\Api; +namespace App\Application\Device\DTO; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Post; -use App\Dto\DeviceRegistrationOutput; -use App\State\DeviceRegistrationProcessor; +use App\UI\State\DeviceRegistrationProcessor; use Symfony\Component\Validator\Constraints as Assert; #[ApiResource(operations: [ new Post( uriTemplate: '/api/v1/devices/register', output: DeviceRegistrationOutput::class, - processor: DeviceRegistrationProcessor::class, read: false, + processor: DeviceRegistrationProcessor::class, ), ])] final class DeviceRegistrationInput @@ -27,4 +26,7 @@ final class DeviceRegistrationInput #[Assert\NotBlank] #[Assert\Length(max: 32)] public string $appVersion = ''; + + #[Assert\Choice(choices: ['protected_person', 'caregiver'])] + public string $deviceType = 'protected_person'; } diff --git a/backend/src/Dto/DeviceRegistrationOutput.php b/backend/src/Application/Device/DTO/DeviceRegistrationOutput.php similarity index 83% rename from backend/src/Dto/DeviceRegistrationOutput.php rename to backend/src/Application/Device/DTO/DeviceRegistrationOutput.php index 4e7fd99..1caf061 100644 --- a/backend/src/Dto/DeviceRegistrationOutput.php +++ b/backend/src/Application/Device/DTO/DeviceRegistrationOutput.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Dto; +namespace App\Application\Device\DTO; final class DeviceRegistrationOutput { diff --git a/backend/src/Application/DeviceRegistrationService.php b/backend/src/Application/Device/Handler/DeviceRegistrationService.php similarity index 54% rename from backend/src/Application/DeviceRegistrationService.php rename to backend/src/Application/Device/Handler/DeviceRegistrationService.php index fd4248b..5164f78 100644 --- a/backend/src/Application/DeviceRegistrationService.php +++ b/backend/src/Application/Device/Handler/DeviceRegistrationService.php @@ -2,29 +2,30 @@ declare(strict_types=1); -namespace App\Application; +namespace App\Application\Device\Handler; +use App\Domain\Device\Port\DeviceRepositoryInterface; use App\Entity\Device; -use App\Security\DeviceTokenHasher; -use Doctrine\ORM\EntityManagerInterface; +use App\Enum\DeviceType; +use App\Infrastructure\Http\Security\DeviceTokenHasher; use Symfony\Component\Uid\Uuid; -final class DeviceRegistrationService +final readonly class DeviceRegistrationService { public function __construct( - private readonly DeviceTokenHasher $tokenHasher, - private readonly EntityManagerInterface $entityManager, + private DeviceTokenHasher $tokenHasher, + private DeviceRepositoryInterface $deviceRepository, ) { } /** @return array{deviceId: string, deviceToken: string} */ - public function register(string $platform, string $appVersion): array + public function register(string $platform, string $appVersion, DeviceType $deviceType = DeviceType::ProtectedPerson): array { $plainToken = $this->tokenHasher->generatePlainToken(); $device = new Device(Uuid::v7()->toRfc4122(), $this->tokenHasher->hash($plainToken), $platform, $appVersion); + $device->setDeviceType($deviceType); - $this->entityManager->persist($device); - $this->entityManager->flush(); + $this->deviceRepository->save($device); return [ 'deviceId' => $device->getPublicId(), diff --git a/backend/src/Controller/.gitignore b/backend/src/Controller/.gitignore deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/Domain/Alert/Port/AlertAcknowledgementRepositoryInterface.php b/backend/src/Domain/Alert/Port/AlertAcknowledgementRepositoryInterface.php new file mode 100644 index 0000000..bd50512 --- /dev/null +++ b/backend/src/Domain/Alert/Port/AlertAcknowledgementRepositoryInterface.php @@ -0,0 +1,16 @@ + */ + public function findByDevice(Device $device, int $limit = 50): array; + + public function save(FallAlert $alert): void; +} diff --git a/backend/src/Domain/Alert/Port/PushAttemptRepositoryInterface.php b/backend/src/Domain/Alert/Port/PushAttemptRepositoryInterface.php new file mode 100644 index 0000000..5b46447 --- /dev/null +++ b/backend/src/Domain/Alert/Port/PushAttemptRepositoryInterface.php @@ -0,0 +1,12 @@ + */ + public function findActiveByProtectedDevice(Device $protectedDevice): array; + + public function findExistingPair(Device $protectedDevice, Device $caregiverDevice): ?CaregiverLink; + + /** @return list */ + public function findByCaregiverDevice(Device $caregiverDevice): array; + + public function save(CaregiverLink $link): void; +} diff --git a/backend/src/Domain/Caregiver/Port/CaregiverPushTokenRepositoryInterface.php b/backend/src/Domain/Caregiver/Port/CaregiverPushTokenRepositoryInterface.php new file mode 100644 index 0000000..3ce09d1 --- /dev/null +++ b/backend/src/Domain/Caregiver/Port/CaregiverPushTokenRepositoryInterface.php @@ -0,0 +1,15 @@ + */ + public function findByDevice(Device $device): array; + + public function deleteForDevice(Device $device): void; +} diff --git a/backend/src/Domain/Device/Port/DeviceRepositoryInterface.php b/backend/src/Domain/Device/Port/DeviceRepositoryInterface.php new file mode 100644 index 0000000..07ecead --- /dev/null +++ b/backend/src/Domain/Device/Port/DeviceRepositoryInterface.php @@ -0,0 +1,14 @@ +id = Uuid::v7(); + $this->acknowledgedAt = new DateTimeImmutable(); + } + + public function getId(): Uuid + { + return $this->id; + } + + public function getFallAlert(): FallAlert + { + return $this->fallAlert; + } + + public function getCaregiverDevice(): Device + { + return $this->caregiverDevice; + } + + public function getAcknowledgedAt(): DateTimeImmutable + { + return $this->acknowledgedAt; + } +} diff --git a/backend/src/Entity/CaregiverInvite.php b/backend/src/Entity/CaregiverInvite.php new file mode 100644 index 0000000..7d31523 --- /dev/null +++ b/backend/src/Entity/CaregiverInvite.php @@ -0,0 +1,81 @@ +id = Uuid::v7(); + $this->createdAt = new DateTimeImmutable(); + } + + public function getId(): Uuid + { + return $this->id; + } + + public function getDevice(): Device + { + return $this->device; + } + + public function getCode(): string + { + return $this->code; + } + + public function getExpiresAt(): DateTimeImmutable + { + return $this->expiresAt; + } + + public function isExpired(): bool + { + return $this->expiresAt <= new DateTimeImmutable(); + } + + public function isUsed(): bool + { + return $this->usedAt instanceof DateTimeImmutable; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + public function getUsedAt(): ?DateTimeImmutable + { + return $this->usedAt; + } + + public function markUsed(): void + { + $this->usedAt = new DateTimeImmutable(); + } +} diff --git a/backend/src/Entity/CaregiverLink.php b/backend/src/Entity/CaregiverLink.php new file mode 100644 index 0000000..e44e7c4 --- /dev/null +++ b/backend/src/Entity/CaregiverLink.php @@ -0,0 +1,83 @@ +id = Uuid::v7(); + $this->createdAt = $now; + $this->updatedAt = $now; + } + + public function getId(): Uuid + { + return $this->id; + } + + public function getProtectedDevice(): Device + { + return $this->protectedDevice; + } + + public function getCaregiverDevice(): Device + { + return $this->caregiverDevice; + } + + public function getStatus(): CaregiverLinkStatus + { + return $this->status; + } + + public function isActive(): bool + { + return CaregiverLinkStatus::Active === $this->status; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + public function getUpdatedAt(): DateTimeImmutable + { + return $this->updatedAt; + } + + public function revoke(): void + { + $this->status = CaregiverLinkStatus::Revoked; + $this->updatedAt = new DateTimeImmutable(); + } +} diff --git a/backend/src/Entity/CaregiverPushToken.php b/backend/src/Entity/CaregiverPushToken.php new file mode 100644 index 0000000..df29a3b --- /dev/null +++ b/backend/src/Entity/CaregiverPushToken.php @@ -0,0 +1,58 @@ +id = Uuid::v7(); + $this->updatedAt = new DateTimeImmutable(); + } + + public function getId(): Uuid + { + return $this->id; + } + + public function getDevice(): Device + { + return $this->device; + } + + public function getFcmToken(): string + { + return $this->fcmToken; + } + + public function getUpdatedAt(): DateTimeImmutable + { + return $this->updatedAt; + } + + public function update(string $fcmToken): void + { + $this->fcmToken = $fcmToken; + $this->updatedAt = new DateTimeImmutable(); + } +} diff --git a/backend/src/Entity/Device.php b/backend/src/Entity/Device.php index 49614dc..379c7c9 100644 --- a/backend/src/Entity/Device.php +++ b/backend/src/Entity/Device.php @@ -4,14 +4,15 @@ namespace App\Entity; -use App\Repository\DeviceRepository; +use App\Enum\DeviceType; +use App\Infrastructure\Persistence\DoctrineDeviceRepository; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; -#[ORM\Entity(repositoryClass: DeviceRepository::class)] +#[ORM\Entity(repositoryClass: DoctrineDeviceRepository::class)] #[ORM\Table(name: 'devices')] #[ORM\UniqueConstraint(name: 'uniq_devices_public_id', columns: ['public_id'])] #[ORM\UniqueConstraint(name: 'uniq_devices_token_hash', columns: ['token_hash'])] @@ -21,18 +22,6 @@ class Device #[ORM\Column(type: 'uuid', unique: true)] private Uuid $id; - #[ORM\Column(name: 'public_id', length: 36)] - private string $publicId; - - #[ORM\Column(name: 'token_hash', length: 64)] - private string $tokenHash; - - #[ORM\Column(length: 16)] - private string $platform; - - #[ORM\Column(name: 'app_version', length: 32)] - private string $appVersion; - #[ORM\Column] private bool $revoked = false; @@ -45,22 +34,25 @@ class Device #[ORM\Column(name: 'last_seen_at', nullable: true)] private ?DateTimeImmutable $lastSeenAt = null; + #[ORM\Column(name: 'device_type', length: 32, enumType: DeviceType::class)] + private DeviceType $deviceType = DeviceType::ProtectedPerson; + /** @var Collection */ - #[ORM\OneToMany(mappedBy: 'device', targetEntity: EmergencyContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + #[ORM\OneToMany(targetEntity: EmergencyContact::class, mappedBy: 'device', cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection $contacts; /** @var Collection */ - #[ORM\OneToMany(mappedBy: 'device', targetEntity: FallAlert::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + #[ORM\OneToMany(targetEntity: FallAlert::class, mappedBy: 'device', cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection $alerts; - public function __construct(string $publicId, string $tokenHash, string $platform, string $appVersion) + public function __construct(#[ORM\Column(name: 'public_id', length: 36)] + private string $publicId, #[ORM\Column(name: 'token_hash', length: 64)] + private string $tokenHash, #[ORM\Column(length: 16)] + private string $platform, #[ORM\Column(name: 'app_version', length: 32)] + private string $appVersion) { $now = new DateTimeImmutable(); $this->id = Uuid::v7(); - $this->publicId = $publicId; - $this->tokenHash = $tokenHash; - $this->platform = $platform; - $this->appVersion = $appVersion; $this->createdAt = $now; $this->updatedAt = $now; $this->contacts = new ArrayCollection(); @@ -109,11 +101,37 @@ public function touchSeenAt(): void $this->touch(); } + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + public function getUpdatedAt(): DateTimeImmutable + { + return $this->updatedAt; + } + public function getLastSeenAt(): ?DateTimeImmutable { return $this->lastSeenAt; } + public function getDeviceType(): DeviceType + { + return $this->deviceType; + } + + public function setDeviceType(DeviceType $deviceType): void + { + $this->deviceType = $deviceType; + $this->touch(); + } + + public function isCaregiver(): bool + { + return DeviceType::Caregiver === $this->deviceType; + } + public function addContact(EmergencyContact $contact): void { if (!$this->contacts->contains($contact)) { diff --git a/backend/src/Entity/EmergencyContact.php b/backend/src/Entity/EmergencyContact.php index b98e1dc..510a7af 100644 --- a/backend/src/Entity/EmergencyContact.php +++ b/backend/src/Entity/EmergencyContact.php @@ -4,12 +4,12 @@ namespace App\Entity; -use App\Repository\EmergencyContactRepository; +use App\Infrastructure\Persistence\DoctrineEmergencyContactRepository; use DateTimeImmutable; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; -#[ORM\Entity(repositoryClass: EmergencyContactRepository::class)] +#[ORM\Entity(repositoryClass: DoctrineEmergencyContactRepository::class)] #[ORM\Table(name: 'emergency_contacts')] #[ORM\UniqueConstraint(name: 'uniq_contacts_device_hash', columns: ['device_id', 'phone_hash'])] class EmergencyContact @@ -18,41 +18,23 @@ class EmergencyContact #[ORM\Column(type: 'uuid', unique: true)] private Uuid $id; - #[ORM\ManyToOne(targetEntity: Device::class, inversedBy: 'contacts')] - #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] - private Device $device; - - #[ORM\Column(length: 100)] - private string $name; - - #[ORM\Column(name: 'phone_ciphertext', type: 'text')] - private string $phoneCiphertext; - - #[ORM\Column(name: 'phone_hash', length: 64)] - private string $phoneHash; - - #[ORM\Column(name: 'phone_last4', length: 4)] - private string $phoneLast4; - - #[ORM\Column] - private int $position; - #[ORM\Column(name: 'created_at')] private DateTimeImmutable $createdAt; #[ORM\Column(name: 'updated_at')] private DateTimeImmutable $updatedAt; - public function __construct(Device $device, string $name, string $phoneCiphertext, string $phoneHash, string $phoneLast4, int $position) + public function __construct(#[ORM\ManyToOne(targetEntity: Device::class, inversedBy: 'contacts')] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + private Device $device, #[ORM\Column(length: 100)] + private string $name, #[ORM\Column(name: 'phone_ciphertext', type: \Doctrine\DBAL\Types\Types::TEXT)] + private string $phoneCiphertext, #[ORM\Column(name: 'phone_hash', length: 64)] + private string $phoneHash, #[ORM\Column(name: 'phone_last4', length: 4)] + private string $phoneLast4, #[ORM\Column] + private int $position) { $now = new DateTimeImmutable(); $this->id = Uuid::v7(); - $this->device = $device; - $this->name = $name; - $this->phoneCiphertext = $phoneCiphertext; - $this->phoneHash = $phoneHash; - $this->phoneLast4 = $phoneLast4; - $this->position = $position; $this->createdAt = $now; $this->updatedAt = $now; } @@ -91,4 +73,14 @@ public function getPosition(): int { return $this->position; } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + public function getUpdatedAt(): DateTimeImmutable + { + return $this->updatedAt; + } } diff --git a/backend/src/Entity/FallAlert.php b/backend/src/Entity/FallAlert.php index a2183a3..76dc98d 100644 --- a/backend/src/Entity/FallAlert.php +++ b/backend/src/Entity/FallAlert.php @@ -5,14 +5,14 @@ namespace App\Entity; use App\Enum\FallAlertStatus; -use App\Repository\FallAlertRepository; +use App\Infrastructure\Persistence\DoctrineFallAlertRepository; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; -#[ORM\Entity(repositoryClass: FallAlertRepository::class)] +#[ORM\Entity(repositoryClass: DoctrineFallAlertRepository::class)] #[ORM\Table(name: 'fall_alerts')] #[ORM\UniqueConstraint(name: 'uniq_alerts_device_client', columns: ['device_id', 'client_alert_id'])] class FallAlert @@ -21,49 +21,36 @@ class FallAlert #[ORM\Column(type: 'uuid', unique: true)] private Uuid $id; - #[ORM\ManyToOne(targetEntity: Device::class, inversedBy: 'alerts')] - #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] - private Device $device; - - #[ORM\Column(name: 'client_alert_id', length: 100)] - private string $clientAlertId; - - #[ORM\Column(name: 'fall_detected_at')] - private DateTimeImmutable $fallDetectedAt; - #[ORM\Column(name: 'received_at')] private DateTimeImmutable $receivedAt; - #[ORM\Column(enumType: FallAlertStatus::class, length: 32)] + #[ORM\Column(length: 32, enumType: FallAlertStatus::class)] private FallAlertStatus $status = FallAlertStatus::Received; - #[ORM\Column(length: 8)] - private string $locale; - - #[ORM\Column(nullable: true)] - private ?float $latitude = null; - - #[ORM\Column(nullable: true)] - private ?float $longitude = null; - #[ORM\Column(name: 'cancelled_at', nullable: true)] private ?DateTimeImmutable $cancelledAt = null; /** @var Collection */ - #[ORM\OneToMany(mappedBy: 'fallAlert', targetEntity: SmsAttempt::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + #[ORM\OneToMany(targetEntity: SmsAttempt::class, mappedBy: 'fallAlert', cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection $smsAttempts; - public function __construct(Device $device, string $clientAlertId, DateTimeImmutable $fallDetectedAt, string $locale, ?float $latitude, ?float $longitude) + /** @var Collection */ + #[ORM\OneToMany(targetEntity: PushAttempt::class, mappedBy: 'fallAlert', cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $pushAttempts; + + public function __construct(#[ORM\ManyToOne(targetEntity: Device::class, inversedBy: 'alerts')] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + private Device $device, #[ORM\Column(name: 'client_alert_id', length: 100)] + private string $clientAlertId, #[ORM\Column(name: 'fall_detected_at')] + private DateTimeImmutable $fallDetectedAt, #[ORM\Column(length: 8)] + private string $locale, #[ORM\Column(nullable: true)] + private ?float $latitude, #[ORM\Column(nullable: true)] + private ?float $longitude) { $this->id = Uuid::v7(); - $this->device = $device; - $this->clientAlertId = $clientAlertId; - $this->fallDetectedAt = $fallDetectedAt; $this->receivedAt = new DateTimeImmutable(); - $this->locale = $locale; - $this->latitude = $latitude; - $this->longitude = $longitude; $this->smsAttempts = new ArrayCollection(); + $this->pushAttempts = new ArrayCollection(); } public function getId(): Uuid @@ -86,6 +73,11 @@ public function getFallDetectedAt(): DateTimeImmutable return $this->fallDetectedAt; } + public function getReceivedAt(): DateTimeImmutable + { + return $this->receivedAt; + } + public function getStatus(): FallAlertStatus { return $this->status; @@ -117,6 +109,11 @@ public function cancel(): void $this->cancelledAt = new DateTimeImmutable(); } + public function markAcknowledged(): void + { + $this->status = FallAlertStatus::Acknowledged; + } + public function getLocale(): string { return $this->locale; @@ -149,4 +146,17 @@ public function addSmsAttempt(SmsAttempt $smsAttempt): void $this->smsAttempts->add($smsAttempt); } } + + /** @return Collection */ + public function getPushAttempts(): Collection + { + return $this->pushAttempts; + } + + public function addPushAttempt(PushAttempt $pushAttempt): void + { + if (!$this->pushAttempts->contains($pushAttempt)) { + $this->pushAttempts->add($pushAttempt); + } + } } diff --git a/backend/src/Entity/PushAttempt.php b/backend/src/Entity/PushAttempt.php new file mode 100644 index 0000000..aac9708 --- /dev/null +++ b/backend/src/Entity/PushAttempt.php @@ -0,0 +1,82 @@ +id = Uuid::v7(); + $this->queuedAt = new DateTimeImmutable(); + } + + public function getId(): Uuid + { + return $this->id; + } + + public function getCaregiverDevice(): Device + { + return $this->caregiverDevice; + } + + public function getStatus(): PushAttemptStatus + { + return $this->status; + } + + public function markSent(?string $providerMessageId): void + { + $this->status = PushAttemptStatus::Sent; + $this->providerMessageId = $providerMessageId; + $this->sentAt = new DateTimeImmutable(); + } + + public function markFailed(?string $errorCode, string $errorMessage): void + { + $this->status = PushAttemptStatus::Failed; + $this->errorCode = $errorCode; + $this->errorMessage = $errorMessage; + ++$this->retryCount; + } +} diff --git a/backend/src/Entity/SmsAttempt.php b/backend/src/Entity/SmsAttempt.php index 745f12e..9dc4ab5 100644 --- a/backend/src/Entity/SmsAttempt.php +++ b/backend/src/Entity/SmsAttempt.php @@ -5,12 +5,12 @@ namespace App\Entity; use App\Enum\SmsAttemptStatus; -use App\Repository\SmsAttemptRepository; +use App\Infrastructure\Persistence\DoctrineSmsAttemptRepository; use DateTimeImmutable; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; -#[ORM\Entity(repositoryClass: SmsAttemptRepository::class)] +#[ORM\Entity(repositoryClass: DoctrineSmsAttemptRepository::class)] #[ORM\Table(name: 'sms_attempts')] class SmsAttempt { @@ -18,27 +18,16 @@ class SmsAttempt #[ORM\Column(type: 'uuid', unique: true)] private Uuid $id; - #[ORM\ManyToOne(targetEntity: FallAlert::class, inversedBy: 'smsAttempts')] - #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] - private FallAlert $fallAlert; - - #[ORM\ManyToOne(targetEntity: EmergencyContact::class)] - #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] - private EmergencyContact $contact; - - #[ORM\Column(length: 32)] - private string $provider; - #[ORM\Column(name: 'provider_message_id', nullable: true)] private ?string $providerMessageId = null; - #[ORM\Column(enumType: SmsAttemptStatus::class, length: 32)] + #[ORM\Column(length: 32, enumType: SmsAttemptStatus::class)] private SmsAttemptStatus $status = SmsAttemptStatus::Queued; #[ORM\Column(name: 'error_code', nullable: true)] private ?string $errorCode = null; - #[ORM\Column(name: 'error_message', type: 'text', nullable: true)] + #[ORM\Column(name: 'error_message', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: true)] private ?string $errorMessage = null; #[ORM\Column(name: 'retry_count')] @@ -53,12 +42,14 @@ class SmsAttempt #[ORM\Column(name: 'delivered_at', nullable: true)] private ?DateTimeImmutable $deliveredAt = null; - public function __construct(FallAlert $fallAlert, EmergencyContact $contact, string $provider) + public function __construct(#[ORM\ManyToOne(targetEntity: FallAlert::class, inversedBy: 'smsAttempts')] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + private FallAlert $fallAlert, #[ORM\ManyToOne(targetEntity: EmergencyContact::class)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + private EmergencyContact $contact, #[ORM\Column(length: 32)] + private string $provider) { $this->id = Uuid::v7(); - $this->fallAlert = $fallAlert; - $this->contact = $contact; - $this->provider = $provider; $this->queuedAt = new DateTimeImmutable(); } diff --git a/backend/src/Enum/CaregiverLinkStatus.php b/backend/src/Enum/CaregiverLinkStatus.php new file mode 100644 index 0000000..4daccd7 --- /dev/null +++ b/backend/src/Enum/CaregiverLinkStatus.php @@ -0,0 +1,12 @@ +deviceRepository->findActiveByTokenHash($userIdentifier); - if (null === $device) { + if (!$device instanceof \App\Entity\Device) { throw new AuthenticationException('Invalid device token.'); } @@ -59,7 +59,7 @@ public function onAuthenticationSuccess(Request $request, TokenInterface $token, return null; } - public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): JsonResponse { return new JsonResponse([ 'error' => 'unauthorized', diff --git a/backend/src/Security/DeviceTokenHasher.php b/backend/src/Infrastructure/Http/Security/DeviceTokenHasher.php similarity index 86% rename from backend/src/Security/DeviceTokenHasher.php rename to backend/src/Infrastructure/Http/Security/DeviceTokenHasher.php index 3956ed9..9429845 100644 --- a/backend/src/Security/DeviceTokenHasher.php +++ b/backend/src/Infrastructure/Http/Security/DeviceTokenHasher.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Security; +namespace App\Infrastructure\Http\Security; final class DeviceTokenHasher { diff --git a/backend/src/Infrastructure/Persistence/DoctrineAlertAcknowledgementRepository.php b/backend/src/Infrastructure/Persistence/DoctrineAlertAcknowledgementRepository.php new file mode 100644 index 0000000..f632aaa --- /dev/null +++ b/backend/src/Infrastructure/Persistence/DoctrineAlertAcknowledgementRepository.php @@ -0,0 +1,40 @@ + + */ +final class DoctrineAlertAcknowledgementRepository extends ServiceEntityRepository implements AlertAcknowledgementRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, AlertAcknowledgement::class); + } + + public function findByCaregiverAndAlert(FallAlert $alert, Device $caregiverDevice): ?AlertAcknowledgement + { + /** @var AlertAcknowledgement|null $ack */ + $ack = $this->findOneBy([ + 'fallAlert' => $alert, + 'caregiverDevice' => $caregiverDevice, + ]); + + return $ack; + } + + public function save(AlertAcknowledgement $ack): void + { + $this->getEntityManager()->persist($ack); + $this->getEntityManager()->flush(); + } +} diff --git a/backend/src/Infrastructure/Persistence/DoctrineCaregiverInviteRepository.php b/backend/src/Infrastructure/Persistence/DoctrineCaregiverInviteRepository.php new file mode 100644 index 0000000..4e0e7d1 --- /dev/null +++ b/backend/src/Infrastructure/Persistence/DoctrineCaregiverInviteRepository.php @@ -0,0 +1,43 @@ + + */ +final class DoctrineCaregiverInviteRepository extends ServiceEntityRepository implements CaregiverInviteRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, CaregiverInvite::class); + } + + public function findActiveByCode(string $code): ?CaregiverInvite + { + /** @var CaregiverInvite|null $invite */ + $invite = $this->createQueryBuilder('invite') + ->andWhere('invite.code = :code') + ->andWhere('invite.usedAt IS NULL') + ->andWhere('invite.expiresAt > :now') + ->setParameter('code', $code) + ->setParameter('now', new DateTimeImmutable()) + ->getQuery() + ->getOneOrNullResult(); + + return $invite; + } + + public function save(CaregiverInvite $invite): void + { + $this->getEntityManager()->persist($invite); + $this->getEntityManager()->flush(); + } +} diff --git a/backend/src/Infrastructure/Persistence/DoctrineCaregiverLinkRepository.php b/backend/src/Infrastructure/Persistence/DoctrineCaregiverLinkRepository.php new file mode 100644 index 0000000..78dbf21 --- /dev/null +++ b/backend/src/Infrastructure/Persistence/DoctrineCaregiverLinkRepository.php @@ -0,0 +1,73 @@ + + */ +final class DoctrineCaregiverLinkRepository extends ServiceEntityRepository implements CaregiverLinkRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, CaregiverLink::class); + } + + /** @return list */ + public function findActiveByProtectedDevice(Device $protectedDevice): array + { + /** @var list $result */ + $result = $this->createQueryBuilder('link') + ->andWhere('link.protectedDevice = :device') + ->andWhere('link.status = :status') + ->setParameter('device', $protectedDevice) + ->setParameter('status', CaregiverLinkStatus::Active) + ->getQuery() + ->getResult(); + + return $result; + } + + public function findExistingPair(Device $protectedDevice, Device $caregiverDevice): ?CaregiverLink + { + /** @var CaregiverLink|null $link */ + $link = $this->createQueryBuilder('link') + ->andWhere('link.protectedDevice = :protected') + ->andWhere('link.caregiverDevice = :caregiver') + ->setParameter('protected', $protectedDevice) + ->setParameter('caregiver', $caregiverDevice) + ->getQuery() + ->getOneOrNullResult(); + + return $link; + } + + /** @return list */ + public function findByCaregiverDevice(Device $caregiverDevice): array + { + /** @var list $result */ + $result = $this->createQueryBuilder('link') + ->andWhere('link.caregiverDevice = :device') + ->andWhere('link.status = :status') + ->setParameter('device', $caregiverDevice) + ->setParameter('status', CaregiverLinkStatus::Active) + ->getQuery() + ->getResult(); + + return $result; + } + + public function save(CaregiverLink $link): void + { + $this->getEntityManager()->persist($link); + $this->getEntityManager()->flush(); + } +} diff --git a/backend/src/Infrastructure/Persistence/DoctrineCaregiverPushTokenRepository.php b/backend/src/Infrastructure/Persistence/DoctrineCaregiverPushTokenRepository.php new file mode 100644 index 0000000..f747479 --- /dev/null +++ b/backend/src/Infrastructure/Persistence/DoctrineCaregiverPushTokenRepository.php @@ -0,0 +1,36 @@ + + */ +final class DoctrineCaregiverPushTokenRepository extends ServiceEntityRepository implements CaregiverPushTokenRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, CaregiverPushToken::class); + } + + public function findByDevice(Device $device): ?CaregiverPushToken + { + /** @var CaregiverPushToken|null $token */ + $token = $this->findOneBy(['device' => $device]); + + return $token; + } + + public function save(CaregiverPushToken $token): void + { + $this->getEntityManager()->persist($token); + $this->getEntityManager()->flush(); + } +} diff --git a/backend/src/Repository/DeviceRepository.php b/backend/src/Infrastructure/Persistence/DoctrineDeviceRepository.php similarity index 60% rename from backend/src/Repository/DeviceRepository.php rename to backend/src/Infrastructure/Persistence/DoctrineDeviceRepository.php index 4e3f71b..1914e0e 100644 --- a/backend/src/Repository/DeviceRepository.php +++ b/backend/src/Infrastructure/Persistence/DoctrineDeviceRepository.php @@ -2,8 +2,9 @@ declare(strict_types=1); -namespace App\Repository; +namespace App\Infrastructure\Persistence; +use App\Domain\Device\Port\DeviceRepositoryInterface; use App\Entity\Device; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -11,7 +12,7 @@ /** * @extends ServiceEntityRepository */ -final class DeviceRepository extends ServiceEntityRepository +final class DoctrineDeviceRepository extends ServiceEntityRepository implements DeviceRepositoryInterface { public function __construct(ManagerRegistry $registry) { @@ -25,4 +26,10 @@ public function findActiveByTokenHash(string $tokenHash): ?Device 'revoked' => false, ]); } + + public function save(Device $device): void + { + $this->getEntityManager()->persist($device); + $this->getEntityManager()->flush(); + } } diff --git a/backend/src/Repository/EmergencyContactRepository.php b/backend/src/Infrastructure/Persistence/DoctrineEmergencyContactRepository.php similarity index 82% rename from backend/src/Repository/EmergencyContactRepository.php rename to backend/src/Infrastructure/Persistence/DoctrineEmergencyContactRepository.php index 8078996..64b70fc 100644 --- a/backend/src/Repository/EmergencyContactRepository.php +++ b/backend/src/Infrastructure/Persistence/DoctrineEmergencyContactRepository.php @@ -2,8 +2,9 @@ declare(strict_types=1); -namespace App\Repository; +namespace App\Infrastructure\Persistence; +use App\Domain\Contact\Port\EmergencyContactRepositoryInterface; use App\Entity\Device; use App\Entity\EmergencyContact; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; @@ -12,7 +13,7 @@ /** * @extends ServiceEntityRepository */ -final class EmergencyContactRepository extends ServiceEntityRepository +final class DoctrineEmergencyContactRepository extends ServiceEntityRepository implements EmergencyContactRepositoryInterface { public function __construct(ManagerRegistry $registry) { diff --git a/backend/src/Infrastructure/Persistence/DoctrineFallAlertRepository.php b/backend/src/Infrastructure/Persistence/DoctrineFallAlertRepository.php new file mode 100644 index 0000000..b7a8b84 --- /dev/null +++ b/backend/src/Infrastructure/Persistence/DoctrineFallAlertRepository.php @@ -0,0 +1,58 @@ + + */ +final class DoctrineFallAlertRepository extends ServiceEntityRepository implements FallAlertRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, FallAlert::class); + } + + public function findOneByDeviceAndClientAlertId(Device $device, string $clientAlertId): ?FallAlert + { + return $this->findOneBy([ + 'device' => $device, + 'clientAlertId' => $clientAlertId, + ]); + } + + public function findById(string $id): ?FallAlert + { + /** @var FallAlert|null $alert */ + $alert = $this->find(Uuid::fromString($id)); + + return $alert; + } + + /** @return list */ + public function findByDevice(Device $device, int $limit = 50): array + { + /** @var list $result */ + $result = $this->findBy( + ['device' => $device], + ['receivedAt' => 'DESC'], + $limit, + ); + + return $result; + } + + public function save(FallAlert $alert): void + { + $this->getEntityManager()->persist($alert); + $this->getEntityManager()->flush(); + } +} diff --git a/backend/src/Infrastructure/Persistence/DoctrinePushAttemptRepository.php b/backend/src/Infrastructure/Persistence/DoctrinePushAttemptRepository.php new file mode 100644 index 0000000..44b0c45 --- /dev/null +++ b/backend/src/Infrastructure/Persistence/DoctrinePushAttemptRepository.php @@ -0,0 +1,27 @@ + + */ +final class DoctrinePushAttemptRepository extends ServiceEntityRepository implements PushAttemptRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, PushAttempt::class); + } + + public function save(PushAttempt $attempt): void + { + $this->getEntityManager()->persist($attempt); + $this->getEntityManager()->flush(); + } +} diff --git a/backend/src/Repository/SmsAttemptRepository.php b/backend/src/Infrastructure/Persistence/DoctrineSmsAttemptRepository.php similarity index 59% rename from backend/src/Repository/SmsAttemptRepository.php rename to backend/src/Infrastructure/Persistence/DoctrineSmsAttemptRepository.php index 28b7f7d..4ea98ed 100644 --- a/backend/src/Repository/SmsAttemptRepository.php +++ b/backend/src/Infrastructure/Persistence/DoctrineSmsAttemptRepository.php @@ -2,8 +2,9 @@ declare(strict_types=1); -namespace App\Repository; +namespace App\Infrastructure\Persistence; +use App\Domain\Alert\Port\SmsAttemptRepositoryInterface; use App\Entity\SmsAttempt; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -11,7 +12,7 @@ /** * @extends ServiceEntityRepository */ -final class SmsAttemptRepository extends ServiceEntityRepository +final class DoctrineSmsAttemptRepository extends ServiceEntityRepository implements SmsAttemptRepositoryInterface { public function __construct(ManagerRegistry $registry) { @@ -22,4 +23,10 @@ public function findOneByProviderMessageId(string $providerMessageId): ?SmsAttem { return $this->findOneBy(['providerMessageId' => $providerMessageId]); } + + public function save(SmsAttempt $attempt): void + { + $this->getEntityManager()->persist($attempt); + $this->getEntityManager()->flush(); + } } diff --git a/backend/src/Infrastructure/Push/DelegatingPushGateway.php b/backend/src/Infrastructure/Push/DelegatingPushGateway.php new file mode 100644 index 0000000..a5d9f0b --- /dev/null +++ b/backend/src/Infrastructure/Push/DelegatingPushGateway.php @@ -0,0 +1,39 @@ +inner()->getProviderName(); + } + + public function send(string $fcmToken, string $alertId, string $fallTimestamp, ?float $latitude, ?float $longitude): array + { + return $this->inner()->send($fcmToken, $alertId, $fallTimestamp, $latitude, $longitude); + } + + private function inner(): PushGatewayInterface + { + return match ($this->provider) { + 'fcm' => $this->fcmPushGateway, + 'fake' => $this->fakePushGateway, + default => throw new InvalidArgumentException(sprintf('Unsupported push provider "%s".', $this->provider)), + }; + } +} diff --git a/backend/src/Infrastructure/Push/FakePushGateway.php b/backend/src/Infrastructure/Push/FakePushGateway.php new file mode 100644 index 0000000..9575327 --- /dev/null +++ b/backend/src/Infrastructure/Push/FakePushGateway.php @@ -0,0 +1,43 @@ +toRfc4122()); + + $this->logger->info('FakePushGateway: push sent', [ + 'providerMessageId' => $providerMessageId, + 'fcmToken' => substr($fcmToken, 0, 12) . '...', + 'alertId' => $alertId, + 'fallTimestamp' => $fallTimestamp, + 'latitude' => $latitude, + 'longitude' => $longitude, + ]); + + return [ + 'providerMessageId' => $providerMessageId, + 'status' => 'sent', + ]; + } +} diff --git a/backend/src/Infrastructure/Push/FcmPushGateway.php b/backend/src/Infrastructure/Push/FcmPushGateway.php new file mode 100644 index 0000000..b611fd7 --- /dev/null +++ b/backend/src/Infrastructure/Push/FcmPushGateway.php @@ -0,0 +1,164 @@ +getAccessToken(); + + $data = [ + 'alertId' => $alertId, + 'fallTimestamp' => $fallTimestamp, + ]; + + if (null !== $latitude) { + $data['latitude'] = (string) $latitude; + } + + if (null !== $longitude) { + $data['longitude'] = (string) $longitude; + } + + $payload = [ + 'message' => [ + 'token' => $fcmToken, + 'data' => $data, + 'android' => [ + 'priority' => 'high', + ], + 'apns' => [ + 'headers' => [ + 'apns-priority' => '10', + ], + ], + ], + ]; + + $response = $this->httpClient->request( + 'POST', + sprintf(self::FCM_SEND_URL, $this->projectId), + [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $accessToken, + 'Content-Type' => 'application/json', + ], + 'body' => json_encode($payload), + ], + ); + + $statusCode = $response->getStatusCode(); + $responseBody = $response->getContent(false); + /** @var array|null $body */ + $body = json_decode($responseBody, true); + + if ($statusCode < 200 || $statusCode >= 300) { + throw new RuntimeException(sprintf( + 'FCM send failed (HTTP %d): %s', + $statusCode, + $responseBody, + )); + } + + $providerMessageId = is_array($body) && isset($body['name']) && is_string($body['name']) + ? $body['name'] + : null; + + return [ + 'providerMessageId' => $providerMessageId, + 'status' => 'sent', + ]; + } + + private function getAccessToken(): string + { + /** @var array|null $serviceAccount */ + $serviceAccount = json_decode($this->serviceAccountJson, true); + + if (!is_array($serviceAccount)) { + throw new RuntimeException('Invalid FCM service account JSON.'); + } + + $clientEmail = is_string($serviceAccount['client_email'] ?? null) ? $serviceAccount['client_email'] : ''; + $privateKeyPem = is_string($serviceAccount['private_key'] ?? null) ? $serviceAccount['private_key'] : ''; + + $now = time(); + $headerJson = json_encode(['alg' => 'RS256', 'typ' => 'JWT']); + $claimsJson = json_encode([ + 'iss' => $clientEmail, + 'scope' => self::OAUTH_SCOPE, + 'aud' => self::OAUTH_TOKEN_URL, + 'iat' => $now, + 'exp' => $now + 3600, + ]); + + if (false === $headerJson || false === $claimsJson) { + throw new RuntimeException('Failed to encode JWT header or claims.'); + } + + $signingInput = base64_encode($headerJson) . '.' . base64_encode($claimsJson); + $privateKey = openssl_pkey_get_private($privateKeyPem); + + if (false === $privateKey) { + throw new RuntimeException('Failed to load FCM private key.'); + } + + openssl_sign($signingInput, $signature, $privateKey, 'SHA256'); + $jwt = $signingInput . '.' . base64_encode((string) $signature); + + $response = $this->httpClient->request('POST', self::OAUTH_TOKEN_URL, [ + 'body' => 'grant_type=' . rawurlencode('urn:ietf:params:oauth:grant-type:jwt-bearer') . '&assertion=' . rawurlencode($jwt), + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + ]); + + /** @var array|null $tokenData */ + $tokenData = json_decode($response->getContent(), true); + + if (!is_array($tokenData) || !isset($tokenData['access_token']) || !is_string($tokenData['access_token'])) { + throw new RuntimeException('Failed to obtain FCM access token.'); + } + + return $tokenData['access_token']; + } +} diff --git a/backend/src/Infrastructure/Sms/DelegatingSmsGateway.php b/backend/src/Infrastructure/Sms/DelegatingSmsGateway.php index dae8b96..c31fef0 100644 --- a/backend/src/Infrastructure/Sms/DelegatingSmsGateway.php +++ b/backend/src/Infrastructure/Sms/DelegatingSmsGateway.php @@ -4,17 +4,17 @@ namespace App\Infrastructure\Sms; -use App\Application\SmsGateway; +use App\Domain\Sms\Port\SmsGatewayInterface; use InvalidArgumentException; use function sprintf; -final class DelegatingSmsGateway implements SmsGateway +final readonly class DelegatingSmsGateway implements SmsGatewayInterface { public function __construct( - private readonly string $provider, - private readonly TwilioSmsGateway $twilioSmsGateway, - private readonly FakeSmsGateway $fakeSmsGateway, + private string $provider, + private TwilioSmsGateway $twilioSmsGateway, + private FakeSmsGateway $fakeSmsGateway, ) { } @@ -28,7 +28,7 @@ public function send(string $to, string $body): array return $this->inner()->send($to, $body); } - private function inner(): SmsGateway + private function inner(): SmsGatewayInterface { return match ($this->provider) { 'twilio' => $this->twilioSmsGateway, diff --git a/backend/src/Infrastructure/Sms/FakeSmsGateway.php b/backend/src/Infrastructure/Sms/FakeSmsGateway.php index c5bcea4..1a2eb67 100644 --- a/backend/src/Infrastructure/Sms/FakeSmsGateway.php +++ b/backend/src/Infrastructure/Sms/FakeSmsGateway.php @@ -4,15 +4,15 @@ namespace App\Infrastructure\Sms; -use App\Application\SmsGateway; +use App\Domain\Sms\Port\SmsGatewayInterface; use function sprintf; use Symfony\Component\Uid\Uuid; -final class FakeSmsGateway implements SmsGateway +final readonly class FakeSmsGateway implements SmsGatewayInterface { - public function __construct(private readonly FakeSmsStore $store) + public function __construct(private FakeSmsStore $store) { } diff --git a/backend/src/Infrastructure/Sms/FakeSmsStore.php b/backend/src/Infrastructure/Sms/FakeSmsStore.php index 5c78bcb..8285bf4 100644 --- a/backend/src/Infrastructure/Sms/FakeSmsStore.php +++ b/backend/src/Infrastructure/Sms/FakeSmsStore.php @@ -29,11 +29,11 @@ use function sprintf; use function trim; -final class FakeSmsStore +final readonly class FakeSmsStore { public function __construct( - private readonly string $projectDir, - private readonly string $shareDir, + private string $projectDir, + private string $shareDir, ) { } @@ -78,6 +78,15 @@ public function all(): array return $entries; } + public function clear(): void + { + $path = $this->path(); + + if (file_exists($path)) { + file_put_contents($path, ''); + } + } + public function append(string $providerMessageId, string $to, string $body): void { $path = $this->path(); @@ -91,7 +100,7 @@ public function append(string $providerMessageId, string $to, string $body): voi 'providerMessageId' => $providerMessageId, 'to' => $to, 'body' => $body, - 'createdAt' => (new DateTimeImmutable())->format(DATE_ATOM), + 'createdAt' => new DateTimeImmutable()->format(DATE_ATOM), ]; file_put_contents( diff --git a/backend/src/Infrastructure/Sms/TwilioSmsGateway.php b/backend/src/Infrastructure/Sms/TwilioSmsGateway.php index 1ac5765..54339c5 100644 --- a/backend/src/Infrastructure/Sms/TwilioSmsGateway.php +++ b/backend/src/Infrastructure/Sms/TwilioSmsGateway.php @@ -4,16 +4,19 @@ namespace App\Infrastructure\Sms; -use App\Application\SmsGateway; +use App\Domain\Sms\Port\SmsGatewayInterface; + +use function in_array; + use RuntimeException; use Twilio\Rest\Client; -final class TwilioSmsGateway implements SmsGateway +final readonly class TwilioSmsGateway implements SmsGatewayInterface { public function __construct( - private readonly string $accountSid, - private readonly string $authToken, - private readonly string $from, + private string $accountSid, + private string $authToken, + private string $from, ) { } @@ -24,7 +27,7 @@ public function getProviderName(): string public function send(string $to, string $body): array { - if ('' === $this->accountSid || '' === $this->authToken || '' === $this->from) { + if (in_array('', [$this->accountSid, $this->authToken, $this->from], true)) { throw new RuntimeException('Twilio credentials are not configured.'); } diff --git a/backend/src/Message/SendFallAlertPushMessage.php b/backend/src/Message/SendFallAlertPushMessage.php new file mode 100644 index 0000000..7625e54 --- /dev/null +++ b/backend/src/Message/SendFallAlertPushMessage.php @@ -0,0 +1,12 @@ + - */ -final class FallAlertRepository extends ServiceEntityRepository -{ - public function __construct(ManagerRegistry $registry) - { - parent::__construct($registry, FallAlert::class); - } - - public function findOneByDeviceAndClientAlertId(Device $device, string $clientAlertId): ?FallAlert - { - return $this->findOneBy([ - 'device' => $device, - 'clientAlertId' => $clientAlertId, - ]); - } -} diff --git a/backend/src/Controller/DebugFakeSmsController.php b/backend/src/UI/Controller/DebugFakeSmsController.php similarity index 81% rename from backend/src/Controller/DebugFakeSmsController.php rename to backend/src/UI/Controller/DebugFakeSmsController.php index 36b301e..7f46a05 100644 --- a/backend/src/Controller/DebugFakeSmsController.php +++ b/backend/src/UI/Controller/DebugFakeSmsController.php @@ -2,18 +2,18 @@ declare(strict_types=1); -namespace App\Controller; +namespace App\UI\Controller; use App\Infrastructure\Sms\FakeSmsStore; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; -final class DebugFakeSmsController +final readonly class DebugFakeSmsController { public function __construct( - private readonly FakeSmsStore $store, - private readonly string $appEnv, + private FakeSmsStore $store, + private string $appEnv, ) { } diff --git a/backend/src/Controller/HealthController.php b/backend/src/UI/Controller/HealthController.php similarity index 93% rename from backend/src/Controller/HealthController.php rename to backend/src/UI/Controller/HealthController.php index 00524e4..074e230 100644 --- a/backend/src/Controller/HealthController.php +++ b/backend/src/UI/Controller/HealthController.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Controller; +namespace App\UI\Controller; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Routing\Attribute\Route; diff --git a/backend/src/Controller/TwilioWebhookController.php b/backend/src/UI/Controller/TwilioWebhookController.php similarity index 73% rename from backend/src/Controller/TwilioWebhookController.php rename to backend/src/UI/Controller/TwilioWebhookController.php index ce4dd2f..84577e9 100644 --- a/backend/src/Controller/TwilioWebhookController.php +++ b/backend/src/UI/Controller/TwilioWebhookController.php @@ -2,19 +2,17 @@ declare(strict_types=1); -namespace App\Controller; +namespace App\UI\Controller; -use App\Repository\SmsAttemptRepository; -use Doctrine\ORM\EntityManagerInterface; +use App\Domain\Alert\Port\SmsAttemptRepositoryInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -final class TwilioWebhookController +final readonly class TwilioWebhookController { public function __construct( - private readonly SmsAttemptRepository $smsAttemptRepository, - private readonly EntityManagerInterface $entityManager, + private SmsAttemptRepositoryInterface $smsAttemptRepository, ) { } @@ -30,13 +28,13 @@ public function __invoke(Request $request): Response $attempt = $this->smsAttemptRepository->findOneByProviderMessageId($messageSid); - if (null === $attempt) { + if (!$attempt instanceof \App\Entity\SmsAttempt) { return new Response(status: Response::HTTP_NO_CONTENT); } if ('delivered' === $messageStatus) { $attempt->markDelivered(); - $this->entityManager->flush(); + $this->smsAttemptRepository->save($attempt); } return new Response(status: Response::HTTP_NO_CONTENT); diff --git a/backend/src/UI/State/AcceptInviteProcessor.php b/backend/src/UI/State/AcceptInviteProcessor.php new file mode 100644 index 0000000..1e6557a --- /dev/null +++ b/backend/src/UI/State/AcceptInviteProcessor.php @@ -0,0 +1,49 @@ + + */ +final readonly class AcceptInviteProcessor implements ProcessorInterface +{ + public function __construct( + private InviteService $inviteService, + private CurrentDeviceProvider $currentDeviceProvider, + ) { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null + { + $rawCode = $uriVariables['code'] ?? ''; + $code = is_string($rawCode) ? $rawCode : ''; + + try { + $this->inviteService->acceptInvite( + $code, + $this->currentDeviceProvider->requireDevice(), + ); + } catch (RuntimeException $e) { + throw new NotFoundHttpException($e->getMessage(), $e); + } catch (DomainException $e) { + throw new UnprocessableEntityHttpException($e->getMessage(), $e); + } + + return null; + } +} diff --git a/backend/src/UI/State/AcknowledgeAlertProcessor.php b/backend/src/UI/State/AcknowledgeAlertProcessor.php new file mode 100644 index 0000000..7fc8434 --- /dev/null +++ b/backend/src/UI/State/AcknowledgeAlertProcessor.php @@ -0,0 +1,65 @@ + + */ +final readonly class AcknowledgeAlertProcessor implements ProcessorInterface +{ + public function __construct( + private CurrentDeviceProvider $currentDeviceProvider, + private FallAlertRepositoryInterface $fallAlertRepository, + private CaregiverLinkRepositoryInterface $caregiverLinkRepository, + private AlertAcknowledgementRepositoryInterface $acknowledgementRepository, + ) { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null + { + $rawId = $uriVariables['id'] ?? ''; + $alertId = is_string($rawId) ? $rawId : ''; + + $alert = $this->fallAlertRepository->findById($alertId); + + if (!$alert instanceof FallAlert) { + throw new NotFoundHttpException('Alert not found.'); + } + + $caregiverDevice = $this->currentDeviceProvider->requireDevice(); + + $links = $this->caregiverLinkRepository->findActiveByProtectedDevice($alert->getDevice()); + $isLinked = array_any($links, static fn ($link) => $link->getCaregiverDevice()->getId()->equals($caregiverDevice->getId())); + + if (!$isLinked) { + throw new AccessDeniedHttpException('You are not linked to this protected person.'); + } + + $existing = $this->acknowledgementRepository->findByCaregiverAndAlert($alert, $caregiverDevice); + + if (!$existing instanceof AlertAcknowledgement) { + $alert->markAcknowledged(); + $ack = new AlertAcknowledgement($alert, $caregiverDevice); + $this->acknowledgementRepository->save($ack); + } + + return null; + } +} diff --git a/backend/src/State/CancelFallAlertProcessor.php b/backend/src/UI/State/CancelFallAlertProcessor.php similarity index 66% rename from backend/src/State/CancelFallAlertProcessor.php rename to backend/src/UI/State/CancelFallAlertProcessor.php index 840f567..1aefcbf 100644 --- a/backend/src/State/CancelFallAlertProcessor.php +++ b/backend/src/UI/State/CancelFallAlertProcessor.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace App\State; +namespace App\UI\State; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; -use App\Api\CancelFallAlertInput; -use App\Api\FallAlertView; -use App\Application\AlertIngestionService; -use App\Security\CurrentDeviceProvider; +use App\Application\Alert\DTO\CancelFallAlertInput; +use App\Application\Alert\DTO\FallAlertView; +use App\Application\Alert\Handler\AlertIngestionService; +use App\Infrastructure\Http\Security\CurrentDeviceProvider; use function is_string; @@ -18,11 +18,11 @@ /** * @implements ProcessorInterface */ -final class CancelFallAlertProcessor implements ProcessorInterface +final readonly class CancelFallAlertProcessor implements ProcessorInterface { public function __construct( - private readonly AlertIngestionService $alertIngestionService, - private readonly CurrentDeviceProvider $currentDeviceProvider, + private AlertIngestionService $alertIngestionService, + private CurrentDeviceProvider $currentDeviceProvider, ) { } @@ -39,7 +39,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $clientAlertId, ); - if (null === $alert) { + if (!$alert instanceof \App\Entity\FallAlert) { throw new NotFoundHttpException('Alert not found.'); } diff --git a/backend/src/UI/State/CaregiverAlertsProvider.php b/backend/src/UI/State/CaregiverAlertsProvider.php new file mode 100644 index 0000000..d3a0d83 --- /dev/null +++ b/backend/src/UI/State/CaregiverAlertsProvider.php @@ -0,0 +1,52 @@ + + */ +final readonly class CaregiverAlertsProvider implements ProviderInterface +{ + public function __construct( + private CurrentDeviceProvider $currentDeviceProvider, + private CaregiverLinkRepositoryInterface $caregiverLinkRepository, + private FallAlertRepositoryInterface $fallAlertRepository, + private AlertAcknowledgementRepositoryInterface $acknowledgementRepository, + ) { + } + + /** @return list */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + $caregiverDevice = $this->currentDeviceProvider->requireDevice(); + + $links = $this->caregiverLinkRepository->findByCaregiverDevice($caregiverDevice); + + if ([] === $links) { + return []; + } + + $result = []; + + foreach ($links as $link) { + $alerts = $this->fallAlertRepository->findByDevice($link->getProtectedDevice()); + + foreach ($alerts as $alert) { + $ack = $this->acknowledgementRepository->findByCaregiverAndAlert($alert, $caregiverDevice); + $result[] = CaregiverAlertView::fromEntity($alert, $ack instanceof \App\Entity\AlertAcknowledgement); + } + } + + return $result; + } +} diff --git a/backend/src/State/CreateFallAlertProcessor.php b/backend/src/UI/State/CreateFallAlertProcessor.php similarity index 65% rename from backend/src/State/CreateFallAlertProcessor.php rename to backend/src/UI/State/CreateFallAlertProcessor.php index 16cf603..b9ba814 100644 --- a/backend/src/State/CreateFallAlertProcessor.php +++ b/backend/src/UI/State/CreateFallAlertProcessor.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace App\State; +namespace App\UI\State; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; -use App\Api\CreateFallAlertInput; -use App\Api\FallAlertView; -use App\Application\AlertIngestionService; -use App\Security\CurrentDeviceProvider; +use App\Application\Alert\DTO\CreateFallAlertInput; +use App\Application\Alert\DTO\FallAlertView; +use App\Application\Alert\Handler\AlertIngestionService; +use App\Infrastructure\Http\Security\CurrentDeviceProvider; use function assert; @@ -18,11 +18,11 @@ /** * @implements ProcessorInterface */ -final class CreateFallAlertProcessor implements ProcessorInterface +final readonly class CreateFallAlertProcessor implements ProcessorInterface { public function __construct( - private readonly AlertIngestionService $alertIngestionService, - private readonly CurrentDeviceProvider $currentDeviceProvider, + private AlertIngestionService $alertIngestionService, + private CurrentDeviceProvider $currentDeviceProvider, ) { } diff --git a/backend/src/UI/State/CreateInviteProcessor.php b/backend/src/UI/State/CreateInviteProcessor.php new file mode 100644 index 0000000..f253664 --- /dev/null +++ b/backend/src/UI/State/CreateInviteProcessor.php @@ -0,0 +1,38 @@ + + */ +final readonly class CreateInviteProcessor implements ProcessorInterface +{ + public function __construct( + private InviteService $inviteService, + private CurrentDeviceProvider $currentDeviceProvider, + ) { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): CreateInviteOutput + { + try { + $invite = $this->inviteService->createInvite( + $this->currentDeviceProvider->requireDevice(), + ); + } catch (DomainException $e) { + throw new UnprocessableEntityHttpException($e->getMessage(), $e); + } + + return CreateInviteOutput::fromInviteData($invite->getCode(), $invite->getExpiresAt()); + } +} diff --git a/backend/src/State/DeviceRegistrationProcessor.php b/backend/src/UI/State/DeviceRegistrationProcessor.php similarity index 54% rename from backend/src/State/DeviceRegistrationProcessor.php rename to backend/src/UI/State/DeviceRegistrationProcessor.php index cc1163b..1a4d2d0 100644 --- a/backend/src/State/DeviceRegistrationProcessor.php +++ b/backend/src/UI/State/DeviceRegistrationProcessor.php @@ -2,22 +2,23 @@ declare(strict_types=1); -namespace App\State; +namespace App\UI\State; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; -use App\Api\DeviceRegistrationInput; -use App\Application\DeviceRegistrationService; -use App\Dto\DeviceRegistrationOutput; +use App\Application\Device\DTO\DeviceRegistrationInput; +use App\Application\Device\DTO\DeviceRegistrationOutput; +use App\Application\Device\Handler\DeviceRegistrationService; +use App\Enum\DeviceType; use function assert; /** * @implements ProcessorInterface */ -final class DeviceRegistrationProcessor implements ProcessorInterface +final readonly class DeviceRegistrationProcessor implements ProcessorInterface { - public function __construct(private readonly DeviceRegistrationService $deviceRegistrationService) + public function __construct(private DeviceRegistrationService $deviceRegistrationService) { } @@ -25,7 +26,11 @@ public function process(mixed $data, Operation $operation, array $uriVariables = { assert($data instanceof DeviceRegistrationInput); - $credentials = $this->deviceRegistrationService->register($data->platform, $data->appVersion); + $credentials = $this->deviceRegistrationService->register( + $data->platform, + $data->appVersion, + DeviceType::from($data->deviceType), + ); return new DeviceRegistrationOutput($credentials['deviceId'], $credentials['deviceToken']); } diff --git a/backend/src/State/FallAlertProvider.php b/backend/src/UI/State/FallAlertProvider.php similarity index 67% rename from backend/src/State/FallAlertProvider.php rename to backend/src/UI/State/FallAlertProvider.php index d54e3a5..6c82e9c 100644 --- a/backend/src/State/FallAlertProvider.php +++ b/backend/src/UI/State/FallAlertProvider.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace App\State; +namespace App\UI\State; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProviderInterface; -use App\Api\FallAlertView; -use App\Application\AlertIngestionService; -use App\Security\CurrentDeviceProvider; +use App\Application\Alert\DTO\FallAlertView; +use App\Application\Alert\Handler\AlertIngestionService; +use App\Infrastructure\Http\Security\CurrentDeviceProvider; use function is_string; @@ -17,11 +17,11 @@ /** * @implements ProviderInterface */ -final class FallAlertProvider implements ProviderInterface +final readonly class FallAlertProvider implements ProviderInterface { public function __construct( - private readonly AlertIngestionService $alertIngestionService, - private readonly CurrentDeviceProvider $currentDeviceProvider, + private AlertIngestionService $alertIngestionService, + private CurrentDeviceProvider $currentDeviceProvider, ) { } @@ -38,7 +38,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $alertId, ); - if (null === $alert) { + if (!$alert instanceof \App\Entity\FallAlert) { throw new NotFoundHttpException('Alert not found.'); } diff --git a/backend/src/UI/State/RegisterPushTokenProcessor.php b/backend/src/UI/State/RegisterPushTokenProcessor.php new file mode 100644 index 0000000..c955725 --- /dev/null +++ b/backend/src/UI/State/RegisterPushTokenProcessor.php @@ -0,0 +1,44 @@ + + */ +final readonly class RegisterPushTokenProcessor implements ProcessorInterface +{ + public function __construct( + private InviteService $inviteService, + private CurrentDeviceProvider $currentDeviceProvider, + ) { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null + { + assert($data instanceof RegisterPushTokenInput); + + try { + $this->inviteService->registerPushToken( + $this->currentDeviceProvider->requireDevice(), + $data->fcmToken, + ); + } catch (DomainException $e) { + throw new UnprocessableEntityHttpException($e->getMessage(), $e); + } + + return null; + } +} diff --git a/backend/src/State/ReplaceContactsProcessor.php b/backend/src/UI/State/ReplaceContactsProcessor.php similarity index 76% rename from backend/src/State/ReplaceContactsProcessor.php rename to backend/src/UI/State/ReplaceContactsProcessor.php index 0092bc0..28ef24a 100644 --- a/backend/src/State/ReplaceContactsProcessor.php +++ b/backend/src/UI/State/ReplaceContactsProcessor.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace App\State; +namespace App\UI\State; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; -use App\Api\ReplaceContactsInput; -use App\Application\ContactSyncService; -use App\Dto\ContactInput; -use App\Dto\ReplaceContactsOutput; -use App\Security\CurrentDeviceProvider; +use App\Application\Contact\DTO\ContactInput; +use App\Application\Contact\DTO\ReplaceContactsInput; +use App\Application\Contact\DTO\ReplaceContactsOutput; +use App\Application\Contact\Handler\ContactSyncService; +use App\Infrastructure\Http\Security\CurrentDeviceProvider; use function assert; @@ -25,11 +25,11 @@ /** * @implements ProcessorInterface */ -final class ReplaceContactsProcessor implements ProcessorInterface +final readonly class ReplaceContactsProcessor implements ProcessorInterface { public function __construct( - private readonly ContactSyncService $contactSyncService, - private readonly CurrentDeviceProvider $currentDeviceProvider, + private ContactSyncService $contactSyncService, + private CurrentDeviceProvider $currentDeviceProvider, ) { } @@ -39,7 +39,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $storedContacts = $this->contactSyncService->replaceContacts( $this->currentDeviceProvider->requireDevice(), - array_map(fn (mixed $contact): array => $this->normalizeContact($contact), $data->contacts), + array_map($this->normalizeContact(...), $data->contacts), ); return new ReplaceContactsOutput($storedContacts); From 279be37f11e0c815dd49c235dff94943cb23c828 Mon Sep 17 00:00:00 2001 From: Thomas Laure Date: Sun, 12 Apr 2026 21:29:29 +0200 Subject: [PATCH 3/7] test(backend): add Behat integration suite and unit tests Sets up FriendsOfBehat/SymfonyExtension with bootstrap, services_test config, and behat.yaml.dist pointing to the test environment. Adds feature files covering device registration, fall alert flow, caregiver invite, and alert acknowledgement. Adds PHPUnit unit tests for all five application handlers/services. Fixes DebugFakeSmsControllerTest isolation by clearing the file-backed store before each run. Co-Authored-By: Claude Sonnet 4.6 --- backend/.env.test | 4 + backend/Makefile | 2 +- backend/behat.yaml.dist | 3 + backend/config/services_test.yaml | 5 + .../features/alert_acknowledgement.feature | 103 ++++++ backend/features/caregiver_invite.feature | 51 +++ backend/features/device_registration.feature | 27 +- backend/features/fall_alert.feature | 71 +++++ backend/tests/Behat/ApiContext.php | 294 +++++++++++++++++- .../Api/DebugFakeSmsControllerTest.php | 9 +- .../Integration/Api/HealthControllerTest.php | 4 +- .../Integration/Api/ReplaceContactsTest.php | 6 +- .../Application/AlertIngestionServiceTest.php | 89 ++++++ .../Application/ContactCryptoServiceTest.php | 2 +- .../DeviceRegistrationServiceTest.php | 49 +++ .../Unit/Application/InviteServiceTest.php | 152 +++++++++ .../Application/PhoneNumberNormalizerTest.php | 2 +- .../SendFallAlertMessageHandlerTest.php | 169 ++++++++++ .../SendFallAlertPushMessageHandlerTest.php | 150 +++++++++ backend/tests/bootstrap.php | 5 +- 20 files changed, 1182 insertions(+), 15 deletions(-) create mode 100644 backend/config/services_test.yaml create mode 100644 backend/features/alert_acknowledgement.feature create mode 100644 backend/features/caregiver_invite.feature create mode 100644 backend/features/fall_alert.feature create mode 100644 backend/tests/Unit/Application/AlertIngestionServiceTest.php create mode 100644 backend/tests/Unit/Application/DeviceRegistrationServiceTest.php create mode 100644 backend/tests/Unit/Application/InviteServiceTest.php create mode 100644 backend/tests/Unit/Application/SendFallAlertMessageHandlerTest.php create mode 100644 backend/tests/Unit/Application/SendFallAlertPushMessageHandlerTest.php diff --git a/backend/.env.test b/backend/.env.test index c19339b..d45f888 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -2,3 +2,7 @@ KERNEL_CLASS='App\Kernel' APP_SECRET='$ecretf0rt3st' SMS_PROVIDER=fake +DEFAULT_URI=http://localhost +PUSH_PROVIDER=fake +FCM_PROJECT_ID=test-project +FCM_SERVICE_ACCOUNT_JSON={} diff --git a/backend/Makefile b/backend/Makefile index fb996cf..3602b3c 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -79,7 +79,7 @@ test: ## Run all PHPUnit tests $(DOCKER_COMPOSE) exec app vendor/bin/phpunit test-behat: ## Run Behat API tests - $(DOCKER_COMPOSE) exec app vendor/bin/behat --colors + $(DOCKER_COMPOSE) exec app vendor/bin/behat --config behat.yaml.dist --colors migrate: ## Run database migrations $(DOCKER_COMPOSE) exec app php bin/console doctrine:migrations:migrate --no-interaction diff --git a/backend/behat.yaml.dist b/backend/behat.yaml.dist index 6f4f19f..6f8c28a 100644 --- a/backend/behat.yaml.dist +++ b/backend/behat.yaml.dist @@ -5,5 +5,8 @@ default: - App\Tests\Behat\ApiContext extensions: FriendsOfBehat\SymfonyExtension: + bootstrap: tests/bootstrap.php kernel: class: App\Kernel + environment: test + debug: true diff --git a/backend/config/services_test.yaml b/backend/config/services_test.yaml new file mode 100644 index 0000000..028d804 --- /dev/null +++ b/backend/config/services_test.yaml @@ -0,0 +1,5 @@ +services: + App\Tests\Behat\ApiContext: + public: true + autowire: true + autoconfigure: true diff --git a/backend/features/alert_acknowledgement.feature b/backend/features/alert_acknowledgement.feature new file mode 100644 index 0000000..cb9a77d --- /dev/null +++ b/backend/features/alert_acknowledgement.feature @@ -0,0 +1,103 @@ +Feature: Alert acknowledgement and caregiver alert history + + Scenario: Caregiver can acknowledge a fall alert + Given I register a protected person device + And I register a caregiver device + And I am authenticated as the protected person + When I send a POST request to "/api/v1/invites" + Then the response status code is 201 + And I store the response JSON field "code" as "inviteCode" + And I am authenticated as the caregiver + When I send a POST request to "/api/v1/invites/{inviteCode}/accept" + Then the response status code is 204 + And I am authenticated as the protected person + When I send a POST request to "/api/v1/fall-alerts" with: + """ + { + "clientAlertId": "ack-behat-001", + "fallTimestamp": "2025-01-01T12:00:00+00:00", + "locale": "en" + } + """ + Then the response status code is 201 + And I store the response JSON field "id" as "id" + And I am authenticated as the caregiver + When I send a POST request to "/api/v1/fall-alerts/{id}/acknowledge" + Then the response status code is 204 + + Scenario: Acknowledging the same alert twice is idempotent + Given I register a protected person device + And I register a caregiver device + And I am authenticated as the protected person + When I send a POST request to "/api/v1/invites" + Then the response status code is 201 + And I store the response JSON field "code" as "inviteCode" + And I am authenticated as the caregiver + When I send a POST request to "/api/v1/invites/{inviteCode}/accept" + Then the response status code is 204 + And I am authenticated as the protected person + When I send a POST request to "/api/v1/fall-alerts" with: + """ + { + "clientAlertId": "ack-behat-idem", + "fallTimestamp": "2025-01-01T12:00:00+00:00", + "locale": "en" + } + """ + Then the response status code is 201 + And I store the response JSON field "id" as "id" + And I am authenticated as the caregiver + When I send a POST request to "/api/v1/fall-alerts/{id}/acknowledge" + Then the response status code is 204 + When I send a POST request to "/api/v1/fall-alerts/{id}/acknowledge" + Then the response status code is 204 + + Scenario: Unlinked caregiver cannot acknowledge an alert + Given I register a protected person device + And I register a caregiver device + And I am authenticated as the protected person + When I send a POST request to "/api/v1/fall-alerts" with: + """ + { + "clientAlertId": "ack-behat-unlinked", + "fallTimestamp": "2025-01-01T12:00:00+00:00", + "locale": "en" + } + """ + Then the response status code is 201 + And I store the response JSON field "id" as "id" + And I am authenticated as the caregiver + When I send a POST request to "/api/v1/fall-alerts/{id}/acknowledge" + Then the response status code is 403 + + Scenario: Caregiver can list alerts for linked protected persons + Given I register a protected person device + And I register a caregiver device + And I am authenticated as the protected person + When I send a POST request to "/api/v1/invites" + Then the response status code is 201 + And I store the response JSON field "code" as "inviteCode" + And I am authenticated as the caregiver + When I send a POST request to "/api/v1/invites/{inviteCode}/accept" + Then the response status code is 204 + And I am authenticated as the protected person + When I send a POST request to "/api/v1/fall-alerts" with: + """ + { + "clientAlertId": "list-behat-001", + "fallTimestamp": "2025-01-01T12:00:00+00:00", + "locale": "en" + } + """ + Then the response status code is 201 + And I am authenticated as the caregiver + When I send a GET request to "/api/v1/caregiver/alerts" + Then the response status code is 200 + And the response is a non-empty collection + + Scenario: Caregiver with no links sees an empty alert list + Given I register a caregiver device + And I am authenticated as the caregiver + When I send a GET request to "/api/v1/caregiver/alerts" + Then the response status code is 200 + And the response is an empty collection diff --git a/backend/features/caregiver_invite.feature b/backend/features/caregiver_invite.feature new file mode 100644 index 0000000..1087f7c --- /dev/null +++ b/backend/features/caregiver_invite.feature @@ -0,0 +1,51 @@ +Feature: Caregiver invite flow + + Scenario: Protected person can generate an invite code + Given I register a protected person device + And I am authenticated as the protected person + When I send a POST request to "/api/v1/invites" + Then the response status code is 201 + And the response JSON field "code" is not empty + And the response JSON field "expiresAt" is not empty + + Scenario: Caregiver can accept an invite and become linked + Given I register a protected person device + And I register a caregiver device + And I am authenticated as the protected person + When I send a POST request to "/api/v1/invites" + Then the response status code is 201 + And I store the response JSON field "code" as "inviteCode" + And I am authenticated as the caregiver + When I send a POST request to "/api/v1/invites/{inviteCode}/accept" + Then the response status code is 204 + + Scenario: Caregiver can register a push token after linking + Given I register a protected person device + And I register a caregiver device + And I am authenticated as the protected person + When I send a POST request to "/api/v1/invites" + Then the response status code is 201 + And I store the response JSON field "code" as "inviteCode" + And I am authenticated as the caregiver + When I send a POST request to "/api/v1/invites/{inviteCode}/accept" + Then the response status code is 204 + When I send a POST request to "/api/v1/caregiver/push-token" with: + """ + {"fcmToken": "test-fcm-token-behat-001"} + """ + Then the response status code is 204 + + Scenario: Accepting a non-existent invite code returns 404 + Given I register a caregiver device + And I am authenticated as the caregiver + When I send a POST request to "/api/v1/invites/BADCODE/accept" + Then the response status code is 404 + + Scenario: A protected person device cannot accept an invite + Given I register a protected person device + And I am authenticated as the protected person + When I send a POST request to "/api/v1/invites" + Then the response status code is 201 + And I store the response JSON field "code" as "inviteCode" + When I send a POST request to "/api/v1/invites/{inviteCode}/accept" + Then the response status code is 422 diff --git a/backend/features/device_registration.feature b/backend/features/device_registration.feature index 42486e0..5556bcb 100644 --- a/backend/features/device_registration.feature +++ b/backend/features/device_registration.feature @@ -1,3 +1,26 @@ Feature: Device registration - Scenario: Register a device - Given nothing + + Scenario: Register a protected person device + When I send a POST request to "/api/v1/devices/register" with: + """ + {"platform": "ios", "appVersion": "1.0.0"} + """ + Then the response status code is 201 + And the response JSON field "deviceId" is not empty + And the response JSON field "deviceToken" is not empty + + Scenario: Register a caregiver device + When I send a POST request to "/api/v1/devices/register" with: + """ + {"platform": "android", "appVersion": "1.0.0", "deviceType": "caregiver"} + """ + Then the response status code is 201 + And the response JSON field "deviceId" is not empty + And the response JSON field "deviceToken" is not empty + + Scenario: Registration fails without required fields + When I send a POST request to "/api/v1/devices/register" with: + """ + {} + """ + Then the response status code is 422 diff --git a/backend/features/fall_alert.feature b/backend/features/fall_alert.feature new file mode 100644 index 0000000..9eebbe4 --- /dev/null +++ b/backend/features/fall_alert.feature @@ -0,0 +1,71 @@ +Feature: Fall alert management + + Scenario: Protected person can create a fall alert + Given I register a protected person device + And I am authenticated as the protected person + When I send a POST request to "/api/v1/fall-alerts" with: + """ + { + "clientAlertId": "fa-behat-001", + "fallTimestamp": "2025-01-01T12:00:00+00:00", + "locale": "en" + } + """ + Then the response status code is 201 + And the response JSON field "id" is not empty + And the response JSON field "status" equals "received" + And the response JSON field "clientAlertId" equals "fa-behat-001" + + Scenario: Creating the same alert twice is idempotent + Given I register a protected person device + And I am authenticated as the protected person + When I send a POST request to "/api/v1/fall-alerts" with: + """ + { + "clientAlertId": "fa-behat-idem", + "fallTimestamp": "2025-01-01T12:00:00+00:00", + "locale": "en" + } + """ + Then the response status code is 201 + And I store the response JSON field "id" as "id" + When I send a POST request to "/api/v1/fall-alerts" with: + """ + { + "clientAlertId": "fa-behat-idem", + "fallTimestamp": "2025-01-01T12:00:00+00:00", + "locale": "en" + } + """ + Then the response status code is 201 + And the response JSON field "id" equals "{id}" + + Scenario: Protected person can cancel a fall alert + Given I register a protected person device + And I am authenticated as the protected person + When I send a POST request to "/api/v1/fall-alerts" with: + """ + { + "clientAlertId": "fa-behat-cancel", + "fallTimestamp": "2025-01-01T12:00:00+00:00", + "locale": "en" + } + """ + Then the response status code is 201 + When I send a POST request to "/api/v1/fall-alerts/fa-behat-cancel/cancel" with: + """ + {} + """ + Then the response status code is 201 + And the response JSON field "status" equals "cancelled" + + Scenario: Creating a fall alert without authentication is rejected + When I send a POST request to "/api/v1/fall-alerts" with: + """ + { + "clientAlertId": "fa-behat-unauth", + "fallTimestamp": "2025-01-01T12:00:00+00:00", + "locale": "en" + } + """ + Then the response status code is 401 diff --git a/backend/tests/Behat/ApiContext.php b/backend/tests/Behat/ApiContext.php index 43f95b8..b98b2c7 100644 --- a/backend/tests/Behat/ApiContext.php +++ b/backend/tests/Behat/ApiContext.php @@ -4,14 +4,304 @@ namespace App\Tests\Behat; +use function array_key_exists; + use Behat\Behat\Context\Context; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use Behat\Gherkin\Node\PyStringNode; + +use function is_array; +use function is_scalar; +use function json_decode; +use function json_encode; + +use const JSON_THROW_ON_ERROR; + +use function preg_replace_callback; + +use RuntimeException; + +use function sprintf; + +use Symfony\Bundle\FrameworkBundle\KernelBrowser; +use Symfony\Component\HttpKernel\KernelInterface; final class ApiContext implements Context { + private readonly KernelBrowser $client; + + /** @var array token per role */ + private array $tokens = []; + + private string $currentToken = ''; + + /** @var array values saved from responses */ + private array $stored = []; + + private int $lastStatusCode = 0; + + /** @var array|null */ + private ?array $lastResponseData = null; + + public function __construct(KernelInterface $kernel) + { + $this->client = new KernelBrowser($kernel); + $this->client->disableReboot(); + } + + // ─── Hooks ───────────────────────────────────────────────────────────────── + + /** + * @BeforeScenario + */ + public function resetScenarioState(BeforeScenarioScope $scope): void + { + $this->tokens = []; + $this->currentToken = ''; + $this->stored = []; + $this->lastStatusCode = 0; + $this->lastResponseData = null; + } + + // ─── Given ───────────────────────────────────────────────────────────────── + + /** + * @Given I register a protected person device + */ + public function iRegisterAProtectedPersonDevice(): void + { + $this->sendRequest('POST', '/api/v1/devices/register', [ + 'platform' => 'ios', + 'appVersion' => '1.0.0', + ]); + + $this->tokens['protected'] = $this->requireResponseField('deviceToken'); + } + + /** + * @Given I register a caregiver device + */ + public function iRegisterACaregiverDevice(): void + { + $this->sendRequest('POST', '/api/v1/devices/register', [ + 'platform' => 'android', + 'appVersion' => '1.0.0', + 'deviceType' => 'caregiver', + ]); + + $this->tokens['caregiver'] = $this->requireResponseField('deviceToken'); + } + + /** + * @Given I am authenticated as the protected person + */ + public function iAmAuthenticatedAsProtectedPerson(): void + { + $this->currentToken = $this->tokens['protected'] + ?? throw new RuntimeException('No protected person registered yet.'); + } + + /** + * @Given I am authenticated as the caregiver + */ + public function iAmAuthenticatedAsCaregiver(): void + { + $this->currentToken = $this->tokens['caregiver'] + ?? throw new RuntimeException('No caregiver registered yet.'); + } + + // ─── When ────────────────────────────────────────────────────────────────── + + /** + * @When I send a POST request to :path + */ + public function iSendAPostRequestTo(string $path): void + { + $this->sendRequest('POST', $this->interpolate($path), []); + } + + /** + * @When I send a POST request to :path with: + */ + public function iSendAPostRequestToWith(string $path, PyStringNode $body): void + { + /** @var array $data */ + $data = json_decode($body->getRaw(), true, 512, JSON_THROW_ON_ERROR); + $this->sendRequest('POST', $this->interpolate($path), $data); + } + + /** + * @When I send a PUT request to :path with: + */ + public function iSendAPutRequestToWith(string $path, PyStringNode $body): void + { + /** @var array $data */ + $data = json_decode($body->getRaw(), true, 512, JSON_THROW_ON_ERROR); + $this->sendRequest('PUT', $this->interpolate($path), $data); + } + /** - * @Given nothing + * @When I send a GET request to :path */ - public function nothing(): void + public function iSendAGetRequestTo(string $path): void { + $this->sendRequest('GET', $this->interpolate($path), null); + } + + // ─── Then ────────────────────────────────────────────────────────────────── + + /** + * @Then the response status code is :code + */ + public function theResponseStatusCodeIs(int $code): void + { + if ($this->lastStatusCode !== $code) { + throw new RuntimeException(sprintf( + 'Expected status %d but got %d. Body: %s', + $code, + $this->lastStatusCode, + json_encode($this->lastResponseData, JSON_THROW_ON_ERROR), + )); + } + } + + /** + * @Then the response JSON field :field equals :value + */ + public function theResponseJsonFieldEquals(string $field, string $value): void + { + $actual = $this->requireResponseField($field); + $expected = $this->interpolate($value); + + if ($actual !== $expected) { + throw new RuntimeException(sprintf( + 'Expected field "%s" to equal "%s" but got "%s".', + $field, + $expected, + $actual, + )); + } + } + + /** + * @Then the response JSON field :field exists + */ + public function theResponseJsonFieldExists(string $field): void + { + $this->requireResponseField($field); + } + + /** + * @Then the response JSON field :field is not empty + */ + public function theResponseJsonFieldIsNotEmpty(string $field): void + { + $value = $this->requireResponseField($field); + + if ('' === $value) { + throw new RuntimeException(sprintf('Expected field "%s" to be non-empty.', $field)); + } + } + + /** + * @Then the response is a non-empty collection + */ + public function theResponseIsANonEmptyCollection(): void + { + // Accepts both hydra collection {"hydra:member":[...]} and plain array [...]. + $members = $this->lastResponseData['hydra:member'] ?? (is_array($this->lastResponseData) && array_is_list($this->lastResponseData) ? $this->lastResponseData : null); + + if (!is_array($members) || [] === $members) { + throw new RuntimeException(sprintf( + 'Expected a non-empty collection but got: %s', + json_encode($this->lastResponseData, JSON_THROW_ON_ERROR), + )); + } + } + + /** + * @Then the response is an empty collection + */ + public function theResponseIsAnEmptyCollection(): void + { + // Accepts both hydra collection {"hydra:member":[]} and plain empty array []. + $members = $this->lastResponseData['hydra:member'] ?? (is_array($this->lastResponseData) && array_is_list($this->lastResponseData) ? $this->lastResponseData : null); + + if (!is_array($members) || [] !== $members) { + throw new RuntimeException(sprintf( + 'Expected an empty collection but got: %s', + json_encode($this->lastResponseData, JSON_THROW_ON_ERROR), + )); + } + } + + /** + * @Then I store the response JSON field :field as :key + */ + public function iStoreTheResponseJsonFieldAs(string $field, string $key): void + { + $this->stored[$key] = $this->requireResponseField($field); + } + + // ─── Helpers ─────────────────────────────────────────────────────────────── + + /** @param array|null $data */ + private function sendRequest(string $method, string $path, ?array $data): void + { + $server = [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + ]; + + if ('' !== $this->currentToken) { + $server['HTTP_AUTHORIZATION'] = sprintf('Bearer %s', $this->currentToken); + } + + $content = null !== $data ? json_encode($data, JSON_THROW_ON_ERROR) : null; + + $this->client->request($method, $path, server: $server, content: $content); + + $response = $this->client->getResponse(); + $this->lastStatusCode = $response->getStatusCode(); + + $body = $response->getContent(); + + if (false !== $body && '' !== $body) { + /** @var array $decoded */ + $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + $this->lastResponseData = $decoded; + } else { + $this->lastResponseData = null; + } + } + + /** Replace {key} placeholders in paths with stored values. */ + private function interpolate(string $path): string + { + return (string) preg_replace_callback('/\{(\w+)\}/', function (array $matches): string { + $key = $matches[1]; + $val = $this->stored[$key] ?? null; + + return is_scalar($val) ? (string) $val : $matches[0]; + }, $path); + } + + private function requireResponseField(string $field): string + { + if (null === $this->lastResponseData || !array_key_exists($field, $this->lastResponseData)) { + throw new RuntimeException(sprintf( + 'Field "%s" not found in response: %s', + $field, + json_encode($this->lastResponseData, JSON_THROW_ON_ERROR), + )); + } + + $value = $this->lastResponseData[$field]; + + if (!is_scalar($value) && null !== $value) { + throw new RuntimeException(sprintf('Field "%s" is not a scalar value.', $field)); + } + + return (string) $value; } } diff --git a/backend/tests/Integration/Api/DebugFakeSmsControllerTest.php b/backend/tests/Integration/Api/DebugFakeSmsControllerTest.php index 3231254..fe44f0f 100644 --- a/backend/tests/Integration/Api/DebugFakeSmsControllerTest.php +++ b/backend/tests/Integration/Api/DebugFakeSmsControllerTest.php @@ -16,11 +16,16 @@ final class DebugFakeSmsControllerTest extends WebTestCase { public function testFakeSmsInboxEndpointReturnsStoredMessages(): void { - $client = static::createClient(); + $client = self::createClient(); + + /** @var \App\Infrastructure\Sms\FakeSmsStore $store */ + $store = self::getContainer()->get(\App\Infrastructure\Sms\FakeSmsStore::class); + $store->clear(); + $gateway = self::getContainer()->get(FakeSmsGateway::class); $gateway->send('+33612345678', 'Hello fake SMS'); - $client->request('GET', '/debug/fake-sms'); + $client->request(\Symfony\Component\HttpFoundation\Request::METHOD_GET, '/debug/fake-sms'); self::assertResponseIsSuccessful(); diff --git a/backend/tests/Integration/Api/HealthControllerTest.php b/backend/tests/Integration/Api/HealthControllerTest.php index c5f538d..5cceea7 100644 --- a/backend/tests/Integration/Api/HealthControllerTest.php +++ b/backend/tests/Integration/Api/HealthControllerTest.php @@ -10,8 +10,8 @@ final class HealthControllerTest extends WebTestCase { public function testHealthEndpointIsAvailable(): void { - $client = static::createClient(); - $client->request('GET', '/health'); + $client = self::createClient(); + $client->request(\Symfony\Component\HttpFoundation\Request::METHOD_GET, '/health'); self::assertResponseIsSuccessful(); } diff --git a/backend/tests/Integration/Api/ReplaceContactsTest.php b/backend/tests/Integration/Api/ReplaceContactsTest.php index aa61b38..d4f217b 100644 --- a/backend/tests/Integration/Api/ReplaceContactsTest.php +++ b/backend/tests/Integration/Api/ReplaceContactsTest.php @@ -14,9 +14,9 @@ final class ReplaceContactsTest extends WebTestCase { public function testContactsCanBeSyncedAfterDeviceRegistration(): void { - $client = static::createClient(); + $client = self::createClient(); - $client->request('POST', '/api/v1/devices/register', server: [ + $client->request(\Symfony\Component\HttpFoundation\Request::METHOD_POST, '/api/v1/devices/register', server: [ 'CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json', ], content: json_encode([ @@ -35,7 +35,7 @@ public function testContactsCanBeSyncedAfterDeviceRegistration(): void self::assertIsString($token); self::assertNotSame('', $token); - $client->request('PUT', '/api/v1/emergency-contacts', server: [ + $client->request(\Symfony\Component\HttpFoundation\Request::METHOD_PUT, '/api/v1/emergency-contacts', server: [ 'CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json', 'HTTP_AUTHORIZATION' => sprintf('Bearer %s', $token), diff --git a/backend/tests/Unit/Application/AlertIngestionServiceTest.php b/backend/tests/Unit/Application/AlertIngestionServiceTest.php new file mode 100644 index 0000000..8338913 --- /dev/null +++ b/backend/tests/Unit/Application/AlertIngestionServiceTest.php @@ -0,0 +1,89 @@ +repository = $this->createMock(FallAlertRepositoryInterface::class); + $this->bus = $this->createMock(MessageBusInterface::class); + $this->service = new AlertIngestionService($this->repository, $this->bus); + } + + #[Test] + public function itCreatesAlertAndDispatchesBothMessages(): void + { + $device = $this->createMock(Device::class); + $this->repository->method('findOneByDeviceAndClientAlertId')->willReturn(null); + $this->repository->expects($this->once())->method('save'); + + $this->bus->expects($this->exactly(2)) + ->method('dispatch') + ->willReturnCallback(static fn (object $msg): Envelope => new Envelope($msg)); + + $alert = $this->service->createAlert($device, 'client-001', new DateTimeImmutable(), 'en', null, null); + + self::assertSame('client-001', $alert->getClientAlertId()); + } + + #[Test] + public function itReturnsExistingAlertIdempotently(): void + { + $device = $this->createMock(Device::class); + $existing = $this->createMock(FallAlert::class); + + $this->repository->method('findOneByDeviceAndClientAlertId')->willReturn($existing); + $this->repository->expects($this->never())->method('save'); + $this->bus->expects($this->never())->method('dispatch'); + + $result = $this->service->createAlert($device, 'client-001', new DateTimeImmutable(), 'en', null, null); + + self::assertSame($existing, $result); + } + + #[Test] + public function itCancelsAlert(): void + { + $device = $this->createMock(Device::class); + $alert = $this->createMock(FallAlert::class); + $alert->expects($this->once())->method('cancel'); + + $this->repository->method('findOneByDeviceAndClientAlertId')->willReturn($alert); + $this->repository->expects($this->once())->method('save')->with($alert); + + $result = $this->service->cancelAlert($device, 'client-001'); + + self::assertSame($alert, $result); + } + + #[Test] + public function itReturnNullWhenCancellingUnknownAlert(): void + { + $device = $this->createMock(Device::class); + $this->repository->method('findOneByDeviceAndClientAlertId')->willReturn(null); + + $result = $this->service->cancelAlert($device, 'unknown'); + + self::assertNull($result); + } +} diff --git a/backend/tests/Unit/Application/ContactCryptoServiceTest.php b/backend/tests/Unit/Application/ContactCryptoServiceTest.php index f3630a7..6b2908c 100644 --- a/backend/tests/Unit/Application/ContactCryptoServiceTest.php +++ b/backend/tests/Unit/Application/ContactCryptoServiceTest.php @@ -4,7 +4,7 @@ namespace App\Tests\Unit\Application; -use App\Application\ContactCryptoService; +use App\Application\Contact\Handler\ContactCryptoService; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; diff --git a/backend/tests/Unit/Application/DeviceRegistrationServiceTest.php b/backend/tests/Unit/Application/DeviceRegistrationServiceTest.php new file mode 100644 index 0000000..61c0b6d --- /dev/null +++ b/backend/tests/Unit/Application/DeviceRegistrationServiceTest.php @@ -0,0 +1,49 @@ +repository = $this->createMock(DeviceRepositoryInterface::class); + // DeviceTokenHasher is final — use the real instance (pure, no I/O) + $this->service = new DeviceRegistrationService(new DeviceTokenHasher(), $this->repository); + } + + #[Test] + public function itRegistersAProtectedPersonDeviceByDefault(): void + { + $this->repository->expects($this->once())->method('save'); + + $result = $this->service->register('ios', '1.0.0'); + + self::assertIsString($result['deviceId']); + self::assertNotEmpty($result['deviceToken']); + } + + #[Test] + public function itRegistersACaregiverDevice(): void + { + $this->repository->expects($this->once())->method('save'); + + $result = $this->service->register('android', '1.0.0', DeviceType::Caregiver); + + self::assertIsString($result['deviceId']); + self::assertNotEmpty($result['deviceToken']); + } +} diff --git a/backend/tests/Unit/Application/InviteServiceTest.php b/backend/tests/Unit/Application/InviteServiceTest.php new file mode 100644 index 0000000..286a4f2 --- /dev/null +++ b/backend/tests/Unit/Application/InviteServiceTest.php @@ -0,0 +1,152 @@ +inviteRepository = $this->createMock(CaregiverInviteRepositoryInterface::class); + $this->linkRepository = $this->createMock(CaregiverLinkRepositoryInterface::class); + $this->pushTokenRepository = $this->createMock(CaregiverPushTokenRepositoryInterface::class); + $this->service = new InviteService( + $this->inviteRepository, + $this->linkRepository, + $this->pushTokenRepository, + ); + } + + #[Test] + public function itCreatesInviteForProtectedPersonDevice(): void + { + $device = $this->createMock(Device::class); + $device->method('isCaregiver')->willReturn(false); + $this->inviteRepository->expects($this->once())->method('save'); + + $invite = $this->service->createInvite($device); + + self::assertInstanceOf(CaregiverInvite::class, $invite); + self::assertSame(8, strlen($invite->getCode())); + } + + #[Test] + public function itRejectsCaregiverDeviceCreatingInvite(): void + { + $device = $this->createMock(Device::class); + $device->method('isCaregiver')->willReturn(true); + + $this->expectException(DomainException::class); + $this->service->createInvite($device); + } + + #[Test] + public function itAcceptsInviteAndCreatesLink(): void + { + $protectedDevice = $this->createMock(Device::class); + $caregiverDevice = $this->createMock(Device::class); + $caregiverDevice->method('isCaregiver')->willReturn(true); + + $invite = $this->createMock(CaregiverInvite::class); + $invite->method('getDevice')->willReturn($protectedDevice); + $invite->expects($this->once())->method('markUsed'); + + $this->inviteRepository->method('findActiveByCode')->with('ABCD1234')->willReturn($invite); + $this->linkRepository->method('findExistingPair')->willReturn(null); + $this->inviteRepository->expects($this->once())->method('save'); + $this->linkRepository->expects($this->once())->method('save'); + + $link = $this->service->acceptInvite('ABCD1234', $caregiverDevice); + + self::assertInstanceOf(CaregiverLink::class, $link); + } + + #[Test] + public function itRejectsProtectedPersonAcceptingInvite(): void + { + $caregiverDevice = $this->createMock(Device::class); + $caregiverDevice->method('isCaregiver')->willReturn(false); + + $this->expectException(DomainException::class); + $this->service->acceptInvite('ABCD1234', $caregiverDevice); + } + + #[Test] + public function itRejectsExpiredOrUnknownInviteCode(): void + { + $caregiverDevice = $this->createMock(Device::class); + $caregiverDevice->method('isCaregiver')->willReturn(true); + $this->inviteRepository->method('findActiveByCode')->willReturn(null); + + $this->expectException(RuntimeException::class); + $this->service->acceptInvite('BADCODE', $caregiverDevice); + } + + #[Test] + public function itRegistersNewPushToken(): void + { + $caregiverDevice = $this->createMock(Device::class); + $caregiverDevice->method('isCaregiver')->willReturn(true); + + $this->pushTokenRepository->method('findByDevice')->willReturn(null); + $this->pushTokenRepository->expects($this->once())->method('save'); + + $token = $this->service->registerPushToken($caregiverDevice, 'fcm-token-xyz'); + + self::assertInstanceOf(CaregiverPushToken::class, $token); + self::assertSame('fcm-token-xyz', $token->getFcmToken()); + } + + #[Test] + public function itUpdatesExistingPushToken(): void + { + $caregiverDevice = $this->createMock(Device::class); + $caregiverDevice->method('isCaregiver')->willReturn(true); + + $existing = $this->createMock(CaregiverPushToken::class); + $existing->expects($this->once())->method('update')->with('new-token'); + $existing->method('getFcmToken')->willReturn('new-token'); + + $this->pushTokenRepository->method('findByDevice')->willReturn($existing); + $this->pushTokenRepository->expects($this->once())->method('save'); + + $token = $this->service->registerPushToken($caregiverDevice, 'new-token'); + + self::assertSame($existing, $token); + } + + #[Test] + public function itRejectsPushTokenForNonCaregiverDevice(): void + { + $device = $this->createMock(Device::class); + $device->method('isCaregiver')->willReturn(false); + + $this->expectException(DomainException::class); + $this->service->registerPushToken($device, 'fcm-token'); + } +} diff --git a/backend/tests/Unit/Application/PhoneNumberNormalizerTest.php b/backend/tests/Unit/Application/PhoneNumberNormalizerTest.php index 37a89c9..218bbab 100644 --- a/backend/tests/Unit/Application/PhoneNumberNormalizerTest.php +++ b/backend/tests/Unit/Application/PhoneNumberNormalizerTest.php @@ -4,7 +4,7 @@ namespace App\Tests\Unit\Application; -use App\Application\PhoneNumberNormalizer; +use App\Application\Contact\Handler\PhoneNumberNormalizer; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; diff --git a/backend/tests/Unit/Application/SendFallAlertMessageHandlerTest.php b/backend/tests/Unit/Application/SendFallAlertMessageHandlerTest.php new file mode 100644 index 0000000..fc01e65 --- /dev/null +++ b/backend/tests/Unit/Application/SendFallAlertMessageHandlerTest.php @@ -0,0 +1,169 @@ +fallAlertRepository = $this->createMock(FallAlertRepositoryInterface::class); + $this->contactRepository = $this->createMock(EmergencyContactRepositoryInterface::class); + // ContactCryptoService and AlertMessageBuilder are final — use real instances + $this->cryptoService = new ContactCryptoService('test-enc-key-32-chars-padding-xx', 'test-hash-key'); + $this->encryptedPhone = $this->cryptoService->encrypt('+33600000000'); + $this->messageBuilder = new AlertMessageBuilder(); + $this->smsGateway = $this->createMock(SmsGatewayInterface::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + + $this->handler = new SendFallAlertMessageHandler( + $this->fallAlertRepository, + $this->contactRepository, + $this->cryptoService, + $this->smsGateway, + $this->messageBuilder, + $this->entityManager, + ); + } + + #[Test] + public function itSkipsUnknownAlert(): void + { + $this->fallAlertRepository->method('findById')->willReturn(null); + $this->smsGateway->expects($this->never())->method('send'); + $this->entityManager->expects($this->never())->method('flush'); + + ($this->handler)(new SendFallAlertMessage('unknown-id')); + } + + #[Test] + public function itSkipsCancelledAlert(): void + { + $alert = $this->createMock(FallAlert::class); + $alert->method('getCancelledAt')->willReturn(new DateTimeImmutable()); + $this->fallAlertRepository->method('findById')->willReturn($alert); + + $this->smsGateway->expects($this->never())->method('send'); + + ($this->handler)(new SendFallAlertMessage('some-id')); + } + + #[Test] + public function itMarksFailedWhenNoContacts(): void + { + $device = $this->createMock(Device::class); + $alert = $this->createMock(FallAlert::class); + $alert->method('getCancelledAt')->willReturn(null); + $alert->method('getDevice')->willReturn($device); + $alert->method('getFallDetectedAt')->willReturn(new DateTimeImmutable()); + $alert->method('getLatitude')->willReturn(null); + $alert->method('getLongitude')->willReturn(null); + + $this->fallAlertRepository->method('findById')->willReturn($alert); + $this->contactRepository->method('findByDevice')->willReturn([]); + + $alert->expects($this->once())->method('markDispatching'); + $alert->expects($this->once())->method('markFailed'); + $this->fallAlertRepository->expects($this->once())->method('save'); + $this->entityManager->expects($this->never())->method('flush'); + + ($this->handler)(new SendFallAlertMessage('some-id')); + } + + #[Test] + public function itSendsSmsToAllContactsAndMarksAlertSent(): void + { + $device = $this->createMock(Device::class); + $alert = $this->createMock(FallAlert::class); + $alert->method('getCancelledAt')->willReturn(null); + $alert->method('getDevice')->willReturn($device); + $alert->method('getId')->willReturn(\Symfony\Component\Uid\Uuid::v7()); + + $alert->method('getFallDetectedAt')->willReturn(new DateTimeImmutable()); + $alert->method('getLatitude')->willReturn(null); + $alert->method('getLongitude')->willReturn(null); + + $contact = $this->createMock(EmergencyContact::class); + $contact->method('getPhoneCiphertext')->willReturn($this->encryptedPhone); + + $this->fallAlertRepository->method('findById')->willReturn($alert); + $this->contactRepository->method('findByDevice')->willReturn([$contact]); + $this->smsGateway->method('getProviderName')->willReturn('fake'); + $this->smsGateway->method('send')->willReturn(['providerMessageId' => 'msg-001']); + + $alert->expects($this->once())->method('markDispatching'); + $alert->expects($this->once())->method('markSent'); + $alert->expects($this->once())->method('addSmsAttempt'); + $this->entityManager->expects($this->once())->method('persist'); + $this->entityManager->expects($this->once())->method('flush'); + + ($this->handler)(new SendFallAlertMessage('some-id')); + } + + #[Test] + public function itMarksPartiallyWhenSomeSendsFail(): void + { + $device = $this->createMock(Device::class); + $alert = $this->createMock(FallAlert::class); + $alert->method('getCancelledAt')->willReturn(null); + $alert->method('getDevice')->willReturn($device); + $alert->method('getId')->willReturn(\Symfony\Component\Uid\Uuid::v7()); + + $alert->method('getFallDetectedAt')->willReturn(new DateTimeImmutable()); + $alert->method('getLatitude')->willReturn(null); + $alert->method('getLongitude')->willReturn(null); + + $contact1 = $this->createMock(EmergencyContact::class); + $contact2 = $this->createMock(EmergencyContact::class); + $contact1->method('getPhoneCiphertext')->willReturn($this->encryptedPhone); + $contact2->method('getPhoneCiphertext')->willReturn($this->encryptedPhone); + + $this->fallAlertRepository->method('findById')->willReturn($alert); + $this->contactRepository->method('findByDevice')->willReturn([$contact1, $contact2]); + $this->smsGateway->method('getProviderName')->willReturn('fake'); + $this->smsGateway->method('send') + ->willReturnOnConsecutiveCalls( + ['providerMessageId' => 'msg-001'], + $this->throwException(new RuntimeException('send failed')), + ); + + $alert->expects($this->once())->method('markPartiallySent'); + + ($this->handler)(new SendFallAlertMessage('some-id')); + } +} diff --git a/backend/tests/Unit/Application/SendFallAlertPushMessageHandlerTest.php b/backend/tests/Unit/Application/SendFallAlertPushMessageHandlerTest.php new file mode 100644 index 0000000..f3d4b7c --- /dev/null +++ b/backend/tests/Unit/Application/SendFallAlertPushMessageHandlerTest.php @@ -0,0 +1,150 @@ +fallAlertRepository = $this->createMock(FallAlertRepositoryInterface::class); + $this->linkRepository = $this->createMock(CaregiverLinkRepositoryInterface::class); + $this->pushTokenRepository = $this->createMock(CaregiverPushTokenRepositoryInterface::class); + $this->pushGateway = $this->createMock(PushGatewayInterface::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + + $this->handler = new SendFallAlertPushMessageHandler( + $this->fallAlertRepository, + $this->linkRepository, + $this->pushTokenRepository, + $this->pushGateway, + $this->entityManager, + ); + } + + #[Test] + public function itSkipsUnknownAlert(): void + { + $this->fallAlertRepository->method('findById')->willReturn(null); + $this->pushGateway->expects($this->never())->method('send'); + + ($this->handler)(new SendFallAlertPushMessage('unknown-id')); + } + + #[Test] + public function itSkipsCancelledAlert(): void + { + $alert = $this->createMock(FallAlert::class); + $alert->method('getCancelledAt')->willReturn(new DateTimeImmutable()); + $this->fallAlertRepository->method('findById')->willReturn($alert); + + $this->pushGateway->expects($this->never())->method('send'); + + ($this->handler)(new SendFallAlertPushMessage('some-id')); + } + + #[Test] + public function itSkipsWhenNoActiveLinks(): void + { + $device = $this->createMock(Device::class); + $alert = $this->createMock(FallAlert::class); + $alert->method('getCancelledAt')->willReturn(null); + $alert->method('getDevice')->willReturn($device); + + $this->fallAlertRepository->method('findById')->willReturn($alert); + $this->linkRepository->method('findActiveByProtectedDevice')->willReturn([]); + + $this->pushGateway->expects($this->never())->method('send'); + $this->entityManager->expects($this->never())->method('flush'); + + ($this->handler)(new SendFallAlertPushMessage('some-id')); + } + + #[Test] + public function itSendsPushToAllCaregiverDevicesWithTokens(): void + { + $protectedDevice = $this->createMock(Device::class); + $caregiverDevice = $this->createMock(Device::class); + $caregiverDevice->method('getId')->willReturn(Uuid::v7()); + + $link = $this->createMock(CaregiverLink::class); + $link->method('getCaregiverDevice')->willReturn($caregiverDevice); + + $pushToken = $this->createMock(CaregiverPushToken::class); + $pushToken->method('getFcmToken')->willReturn('fcm-token-abc'); + + $alert = $this->createMock(FallAlert::class); + $alert->method('getCancelledAt')->willReturn(null); + $alert->method('getDevice')->willReturn($protectedDevice); + $alert->method('getId')->willReturn(Uuid::v7()); + $alert->method('getFallDetectedAt')->willReturn(new DateTimeImmutable('2025-01-01T12:00:00+00:00')); + $alert->method('getLatitude')->willReturn(null); + $alert->method('getLongitude')->willReturn(null); + + $this->fallAlertRepository->method('findById')->willReturn($alert); + $this->linkRepository->method('findActiveByProtectedDevice')->willReturn([$link]); + $this->pushTokenRepository->method('findByDevice')->with($caregiverDevice)->willReturn($pushToken); + $this->pushGateway->method('getProviderName')->willReturn('fake'); + $this->pushGateway->expects($this->once())->method('send')->willReturn(['providerMessageId' => 'push-001']); + + $alert->expects($this->once())->method('addPushAttempt'); + $this->entityManager->expects($this->once())->method('persist'); + $this->entityManager->expects($this->once())->method('flush'); + + ($this->handler)(new SendFallAlertPushMessage('some-id')); + } + + #[Test] + public function itSkipsCaregiverWithNoToken(): void + { + $protectedDevice = $this->createMock(Device::class); + $caregiverDevice = $this->createMock(Device::class); + + $link = $this->createMock(CaregiverLink::class); + $link->method('getCaregiverDevice')->willReturn($caregiverDevice); + + $alert = $this->createMock(FallAlert::class); + $alert->method('getCancelledAt')->willReturn(null); + $alert->method('getDevice')->willReturn($protectedDevice); + $alert->method('getFallDetectedAt')->willReturn(new DateTimeImmutable()); + + $this->fallAlertRepository->method('findById')->willReturn($alert); + $this->linkRepository->method('findActiveByProtectedDevice')->willReturn([$link]); + $this->pushTokenRepository->method('findByDevice')->willReturn(null); + + $this->pushGateway->expects($this->never())->method('send'); + $this->entityManager->expects($this->once())->method('flush'); + + ($this->handler)(new SendFallAlertPushMessage('some-id')); + } +} diff --git a/backend/tests/bootstrap.php b/backend/tests/bootstrap.php index 7e27b86..3e8bf63 100644 --- a/backend/tests/bootstrap.php +++ b/backend/tests/bootstrap.php @@ -6,6 +6,9 @@ require dirname(__DIR__) . '/vendor/autoload.php'; +// Force test environment so bootEnv loads .env.test overrides. +$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = 'test'; + if (file_exists(dirname(__DIR__) . '/.env')) { - (new Dotenv())->bootEnv(dirname(__DIR__) . '/.env'); + new Dotenv()->bootEnv(dirname(__DIR__) . '/.env'); } From 0d28cd6d21c92d7a1b389982dd76fd49abc5ec36 Mon Sep 17 00:00:00 2001 From: Thomas Laure Date: Sun, 12 Apr 2026 21:29:38 +0200 Subject: [PATCH 4/7] feat(caregiver_app): add Flutter caregiver app scaffold New Flutter app for caregivers with device registration, invite-code linking, FCM push notification reception, and alert screens (ActiveAlertScreen, LinkScreen, HomeScreen). Uses firebase_messaging for data-only push handling in all app states. Co-Authored-By: Claude Sonnet 4.6 --- caregiver_app/.gitignore | 45 ++ caregiver_app/README.md | 17 + caregiver_app/analysis_options.yaml | 28 + caregiver_app/android/.gitignore | 14 + caregiver_app/android/app/build.gradle.kts | 44 ++ .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 45 ++ .../caregiver_app/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + caregiver_app/android/build.gradle.kts | 24 + caregiver_app/android/gradle.properties | 2 + .../gradle/wrapper/gradle-wrapper.properties | 5 + caregiver_app/android/settings.gradle.kts | 26 + caregiver_app/ios/.gitignore | 34 + .../ios/Flutter/AppFrameworkInfo.plist | 24 + caregiver_app/ios/Flutter/Debug.xcconfig | 2 + caregiver_app/ios/Flutter/Release.xcconfig | 2 + caregiver_app/ios/Podfile | 43 ++ .../ios/Runner.xcodeproj/project.pbxproj | 647 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 119 ++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + caregiver_app/ios/Runner/AppDelegate.swift | 16 + .../AppIcon.appiconset/Contents.json | 122 ++++ .../Icon-App-1024x1024@1x.png | Bin 0 -> 10932 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 295 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 450 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 282 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 462 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 704 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 586 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 1674 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 762 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 1226 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 1418 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + .../Runner/Base.lproj/LaunchScreen.storyboard | 37 + .../ios/Runner/Base.lproj/Main.storyboard | 26 + caregiver_app/ios/Runner/Info.plist | 70 ++ .../ios/Runner/Runner-Bridging-Header.h | 1 + caregiver_app/ios/Runner/SceneDelegate.swift | 6 + .../ios/RunnerTests/RunnerTests.swift | 12 + caregiver_app/lib/main.dart | 126 ++++ .../lib/screens/active_alert_screen.dart | 183 +++++ caregiver_app/lib/screens/home_screen.dart | 186 +++++ caregiver_app/lib/screens/link_screen.dart | 154 +++++ .../services/caregiver_backend_service.dart | 187 +++++ .../services/push_notification_service.dart | 72 ++ caregiver_app/pubspec.lock | 562 +++++++++++++++ caregiver_app/pubspec.yaml | 99 +++ caregiver_app/test/widget_test.dart | 13 + 72 files changed, 3146 insertions(+) create mode 100644 caregiver_app/.gitignore create mode 100644 caregiver_app/README.md create mode 100644 caregiver_app/analysis_options.yaml create mode 100644 caregiver_app/android/.gitignore create mode 100644 caregiver_app/android/app/build.gradle.kts create mode 100644 caregiver_app/android/app/src/debug/AndroidManifest.xml create mode 100644 caregiver_app/android/app/src/main/AndroidManifest.xml create mode 100644 caregiver_app/android/app/src/main/kotlin/com/fallguardian/caregiver_app/MainActivity.kt create mode 100644 caregiver_app/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 caregiver_app/android/app/src/main/res/drawable/launch_background.xml create mode 100644 caregiver_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 caregiver_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 caregiver_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 caregiver_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 caregiver_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 caregiver_app/android/app/src/main/res/values-night/styles.xml create mode 100644 caregiver_app/android/app/src/main/res/values/styles.xml create mode 100644 caregiver_app/android/app/src/profile/AndroidManifest.xml create mode 100644 caregiver_app/android/build.gradle.kts create mode 100644 caregiver_app/android/gradle.properties create mode 100644 caregiver_app/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 caregiver_app/android/settings.gradle.kts create mode 100644 caregiver_app/ios/.gitignore create mode 100644 caregiver_app/ios/Flutter/AppFrameworkInfo.plist create mode 100644 caregiver_app/ios/Flutter/Debug.xcconfig create mode 100644 caregiver_app/ios/Flutter/Release.xcconfig create mode 100644 caregiver_app/ios/Podfile create mode 100644 caregiver_app/ios/Runner.xcodeproj/project.pbxproj create mode 100644 caregiver_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 caregiver_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 caregiver_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 caregiver_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 caregiver_app/ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 caregiver_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 caregiver_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 caregiver_app/ios/Runner/AppDelegate.swift create mode 100644 caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 caregiver_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 caregiver_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 caregiver_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 caregiver_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 caregiver_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 caregiver_app/ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 caregiver_app/ios/Runner/Base.lproj/Main.storyboard create mode 100644 caregiver_app/ios/Runner/Info.plist create mode 100644 caregiver_app/ios/Runner/Runner-Bridging-Header.h create mode 100644 caregiver_app/ios/Runner/SceneDelegate.swift create mode 100644 caregiver_app/ios/RunnerTests/RunnerTests.swift create mode 100644 caregiver_app/lib/main.dart create mode 100644 caregiver_app/lib/screens/active_alert_screen.dart create mode 100644 caregiver_app/lib/screens/home_screen.dart create mode 100644 caregiver_app/lib/screens/link_screen.dart create mode 100644 caregiver_app/lib/services/caregiver_backend_service.dart create mode 100644 caregiver_app/lib/services/push_notification_service.dart create mode 100644 caregiver_app/pubspec.lock create mode 100644 caregiver_app/pubspec.yaml create mode 100644 caregiver_app/test/widget_test.dart diff --git a/caregiver_app/.gitignore b/caregiver_app/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/caregiver_app/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/caregiver_app/README.md b/caregiver_app/README.md new file mode 100644 index 0000000..9da8f9d --- /dev/null +++ b/caregiver_app/README.md @@ -0,0 +1,17 @@ +# caregiver_app + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/caregiver_app/analysis_options.yaml b/caregiver_app/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/caregiver_app/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/caregiver_app/android/.gitignore b/caregiver_app/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/caregiver_app/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/caregiver_app/android/app/build.gradle.kts b/caregiver_app/android/app/build.gradle.kts new file mode 100644 index 0000000..cecabda --- /dev/null +++ b/caregiver_app/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.fallguardian.caregiver_app" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.fallguardian.caregiver_app" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/caregiver_app/android/app/src/debug/AndroidManifest.xml b/caregiver_app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/caregiver_app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/caregiver_app/android/app/src/main/AndroidManifest.xml b/caregiver_app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5f86a2 --- /dev/null +++ b/caregiver_app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/caregiver_app/android/app/src/main/kotlin/com/fallguardian/caregiver_app/MainActivity.kt b/caregiver_app/android/app/src/main/kotlin/com/fallguardian/caregiver_app/MainActivity.kt new file mode 100644 index 0000000..97c35f5 --- /dev/null +++ b/caregiver_app/android/app/src/main/kotlin/com/fallguardian/caregiver_app/MainActivity.kt @@ -0,0 +1,5 @@ +package com.fallguardian.caregiver_app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/caregiver_app/android/app/src/main/res/drawable-v21/launch_background.xml b/caregiver_app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/caregiver_app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/caregiver_app/android/app/src/main/res/drawable/launch_background.xml b/caregiver_app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/caregiver_app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/caregiver_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/caregiver_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/caregiver_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/caregiver_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/caregiver_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/caregiver_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/caregiver_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/caregiver_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/caregiver_app/android/app/src/main/res/values-night/styles.xml b/caregiver_app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/caregiver_app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/caregiver_app/android/app/src/main/res/values/styles.xml b/caregiver_app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/caregiver_app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/caregiver_app/android/app/src/profile/AndroidManifest.xml b/caregiver_app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/caregiver_app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/caregiver_app/android/build.gradle.kts b/caregiver_app/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/caregiver_app/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/caregiver_app/android/gradle.properties b/caregiver_app/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/caregiver_app/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/caregiver_app/android/gradle/wrapper/gradle-wrapper.properties b/caregiver_app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/caregiver_app/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/caregiver_app/android/settings.gradle.kts b/caregiver_app/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/caregiver_app/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/caregiver_app/ios/.gitignore b/caregiver_app/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/caregiver_app/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/caregiver_app/ios/Flutter/AppFrameworkInfo.plist b/caregiver_app/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/caregiver_app/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/caregiver_app/ios/Flutter/Debug.xcconfig b/caregiver_app/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/caregiver_app/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/caregiver_app/ios/Flutter/Release.xcconfig b/caregiver_app/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/caregiver_app/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/caregiver_app/ios/Podfile b/caregiver_app/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/caregiver_app/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/caregiver_app/ios/Runner.xcodeproj/project.pbxproj b/caregiver_app/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..ac6dff6 --- /dev/null +++ b/caregiver_app/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,647 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = PTXCAH5P4R; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.fallguardian.caregiverApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.fallguardian.caregiverApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.fallguardian.caregiverApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.fallguardian.caregiverApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = PTXCAH5P4R; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.fallguardian.caregiverApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = PTXCAH5P4R; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.fallguardian.caregiverApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/caregiver_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/caregiver_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/caregiver_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/caregiver_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/caregiver_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/caregiver_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/caregiver_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/caregiver_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/caregiver_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/caregiver_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/caregiver_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/caregiver_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/caregiver_app/ios/Runner.xcworkspace/contents.xcworkspacedata b/caregiver_app/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/caregiver_app/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/caregiver_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/caregiver_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/caregiver_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/caregiver_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/caregiver_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/caregiver_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/caregiver_app/ios/Runner/AppDelegate.swift b/caregiver_app/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/caregiver_app/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..dc9ada4725e9b0ddb1deab583e5b5102493aa332 GIT binary patch literal 10932 zcmeHN2~<R zh`|8`A_PQ1nSu(UMFx?8j8PC!!VDphaL#`F42fd#7Vlc`zIE4n%Y~eiz4y1j|NDpi z?<@|pSJ-HM`qifhf@m%MamgwK83`XpBA<+azdF#2QsT{X@z0A9Bq>~TVErigKH1~P zRX-!h-f0NJ4Mh++{D}J+K>~~rq}d%o%+4dogzXp7RxX4C>Km5XEI|PAFDmo;DFm6G zzjVoB`@qW98Yl0Kvc-9w09^PrsobmG*Eju^=3f?0o-t$U)TL1B3;sZ^!++3&bGZ!o-*6w?;oOhf z=A+Qb$scV5!RbG+&2S}BQ6YH!FKb0``VVX~T$dzzeSZ$&9=X$3)_7Z{SspSYJ!lGE z7yig_41zpQ)%5dr4ff0rh$@ky3-JLRk&DK)NEIHecf9c*?Z1bUB4%pZjQ7hD!A0r-@NF(^WKdr(LXj|=UE7?gBYGgGQV zidf2`ZT@pzXf7}!NH4q(0IMcxsUGDih(0{kRSez&z?CFA0RVXsVFw3^u=^KMtt95q z43q$b*6#uQDLoiCAF_{RFc{!H^moH_cmll#Fc^KXi{9GDl{>%+3qyfOE5;Zq|6#Hb zp^#1G+z^AXfRKaa9HK;%b3Ux~U@q?xg<2DXP%6k!3E)PA<#4$ui8eDy5|9hA5&{?v z(-;*1%(1~-NTQ`Is1_MGdQ{+i*ccd96ab$R$T3=% zw_KuNF@vI!A>>Y_2pl9L{9h1-C6H8<)J4gKI6{WzGBi<@u3P6hNsXG=bRq5c+z;Gc3VUCe;LIIFDmQAGy+=mRyF++u=drBWV8-^>0yE9N&*05XHZpPlE zxu@?8(ZNy7rm?|<+UNe0Vs6&o?l`Pt>P&WaL~M&#Eh%`rg@Mbb)J&@DA-wheQ>hRV z<(XhigZAT z>=M;URcdCaiO3d^?H<^EiEMDV+7HsTiOhoaMX%P65E<(5xMPJKxf!0u>U~uVqnPN7T!X!o@_gs3Ct1 zlZ_$5QXP4{Aj645wG_SNT&6m|O6~Tsl$q?nK*)(`{J4b=(yb^nOATtF1_aS978$x3 zx>Q@s4i3~IT*+l{@dx~Hst21fR*+5}S1@cf>&8*uLw-0^zK(+OpW?cS-YG1QBZ5q! zgTAgivzoF#`cSz&HL>Ti!!v#?36I1*l^mkrx7Y|K6L#n!-~5=d3;K<;Zqi|gpNUn_ z_^GaQDEQ*jfzh;`j&KXb66fWEk1K7vxQIMQ_#Wu_%3 z4Oeb7FJ`8I>Px;^S?)}2+4D_83gHEq>8qSQY0PVP?o)zAv3K~;R$fnwTmI-=ZLK`= zTm+0h*e+Yfr(IlH3i7gUclNH^!MU>id$Jw>O?2i0Cila#v|twub21@e{S2v}8Z13( zNDrTXZVgris|qYm<0NU(tAPouG!QF4ZNpZPkX~{tVf8xY690JqY1NVdiTtW+NqyRP zZ&;T0ikb8V{wxmFhlLTQ&?OP7 z;(z*<+?J2~z*6asSe7h`$8~Se(@t(#%?BGLVs$p``;CyvcT?7Y!{tIPva$LxCQ&4W z6v#F*);|RXvI%qnoOY&i4S*EL&h%hP3O zLsrFZhv&Hu5tF$Lx!8(hs&?!Kx5&L(fdu}UI5d*wn~A`nPUhG&Rv z2#ixiJdhSF-K2tpVL=)5UkXRuPAFrEW}7mW=uAmtVQ&pGE-&az6@#-(Te^n*lrH^m@X-ftVcwO_#7{WI)5v(?>uC9GG{lcGXYJ~Q8q zbMFl7;t+kV;|;KkBW2!P_o%Czhw&Q(nXlxK9ak&6r5t_KH8#1Mr-*0}2h8R9XNkr zto5-b7P_auqTJb(TJlmJ9xreA=6d=d)CVbYP-r4$hDn5|TIhB>SReMfh&OVLkMk-T zYf%$taLF0OqYF?V{+6Xkn>iX@TuqQ?&cN6UjC9YF&%q{Ut3zv{U2)~$>-3;Dp)*(? zg*$mu8^i=-e#acaj*T$pNowo{xiGEk$%DusaQiS!KjJH96XZ-hXv+jk%ard#fu=@Q z$AM)YWvE^{%tDfK%nD49=PI|wYu}lYVbB#a7wtN^Nml@CE@{Gv7+jo{_V?I*jkdLD zJE|jfdrmVbkfS>rN*+`#l%ZUi5_bMS<>=MBDNlpiSb_tAF|Zy`K7kcp@|d?yaTmB^ zo?(vg;B$vxS|SszusORgDg-*Uitzdi{dUV+glA~R8V(?`3GZIl^egW{a919!j#>f` znL1o_^-b`}xnU0+~KIFLQ)$Q6#ym%)(GYC`^XM*{g zv3AM5$+TtDRs%`2TyR^$(hqE7Y1b&`Jd6dS6B#hDVbJlUXcG3y*439D8MrK!2D~6gn>UD4Imctb z+IvAt0iaW73Iq$K?4}H`7wq6YkTMm`tcktXgK0lKPmh=>h+l}Y+pDtvHnG>uqBA)l zAH6BV4F}v$(o$8Gfo*PB>IuaY1*^*`OTx4|hM8jZ?B6HY;F6p4{`OcZZ(us-RVwDx zUzJrCQlp@mz1ZFiSZ*$yX3c_#h9J;yBE$2g%xjmGF4ca z&yL`nGVs!Zxsh^j6i%$a*I3ZD2SoNT`{D%mU=LKaEwbN(_J5%i-6Va?@*>=3(dQy` zOv%$_9lcy9+(t>qohkuU4r_P=R^6ME+wFu&LA9tw9RA?azGhjrVJKy&8=*qZT5Dr8g--d+S8zAyJ$1HlW3Olryt`yE zFIph~Z6oF&o64rw{>lgZISC6p^CBer9C5G6yq%?8tC+)7*d+ib^?fU!JRFxynRLEZ zj;?PwtS}Ao#9whV@KEmwQgM0TVP{hs>dg(1*DiMUOKHdQGIqa0`yZnHk9mtbPfoLx zo;^V6pKUJ!5#n`w2D&381#5#_t}AlTGEgDz$^;u;-vxDN?^#5!zN9ngytY@oTv!nc zp1Xn8uR$1Z;7vY`-<*?DfPHB;x|GUi_fI9@I9SVRv1)qETbNU_8{5U|(>Du84qP#7 z*l9Y$SgA&wGbj>R1YeT9vYjZuC@|{rajTL0f%N@>3$DFU=`lSPl=Iv;EjuGjBa$Gw zHD-;%YOE@<-!7-Mn`0WuO3oWuL6tB2cpPw~Nvuj|KM@))ixuDK`9;jGMe2d)7gHin zS<>k@!x;!TJEc#HdL#RF(`|4W+H88d4V%zlh(7#{q2d0OQX9*FW^`^_<3r$kabWAB z$9BONo5}*(%kx zOXi-yM_cmB3>inPpI~)duvZykJ@^^aWzQ=eQ&STUa}2uT@lV&WoRzkUoE`rR0)`=l zFT%f|LA9fCw>`enm$p7W^E@U7RNBtsh{_-7vVz3DtB*y#*~(L9+x9*wn8VjWw|Q~q zKFsj1Yl>;}%MG3=PY`$g$_mnyhuV&~O~u~)968$0b2!Jkd;2MtAP#ZDYw9hmK_+M$ zb3pxyYC&|CuAbtiG8HZjj?MZJBFbt`ryf+c1dXFuC z0*ZQhBzNBd*}s6K_G}(|Z_9NDV162#y%WSNe|FTDDhx)K!c(mMJh@h87@8(^YdK$&d*^WQe8Z53 z(|@MRJ$Lk-&ii74MPIs80WsOFZ(NX23oR-?As+*aq6b?~62@fSVmM-_*cb1RzZ)`5$agEiL`-E9s7{GM2?(KNPgK1(+c*|-FKoy}X(D_b#etO|YR z(BGZ)0Ntfv-7R4GHoXp?l5g#*={S1{u-QzxCGng*oWr~@X-5f~RA14b8~B+pLKvr4 zfgL|7I>jlak9>D4=(i(cqYf7#318!OSR=^`xxvI!bBlS??`xxWeg?+|>MxaIdH1U~#1tHu zB{QMR?EGRmQ_l4p6YXJ{o(hh-7Tdm>TAX380TZZZyVkqHNzjUn*_|cb?T? zt;d2s-?B#Mc>T-gvBmQZx(y_cfkXZO~{N zT6rP7SD6g~n9QJ)8F*8uHxTLCAZ{l1Y&?6v)BOJZ)=R-pY=Y=&1}jE7fQ>USS}xP#exo57uND0i*rEk@$;nLvRB@u~s^dwRf?G?_enN@$t* zbL%JO=rV(3Ju8#GqUpeE3l_Wu1lN9Y{D4uaUe`g>zlj$1ER$6S6@{m1!~V|bYkhZA z%CvrDRTkHuajMU8;&RZ&itnC~iYLW4DVkP<$}>#&(`UO>!n)Po;Mt(SY8Yb`AS9lt znbX^i?Oe9r_o=?})IHKHoQGKXsps_SE{hwrg?6dMI|^+$CeC&z@*LuF+P`7LfZ*yr+KN8B4{Nzv<`A(wyR@!|gw{zB6Ha ziwPAYh)oJ(nlqSknu(8g9N&1hu0$vFK$W#mp%>X~AU1ay+EKWcFdif{% z#4!4aoVVJ;ULmkQf!ke2}3hqxLK>eq|-d7Ly7-J9zMpT`?dxo6HdfJA|t)?qPEVBDv z{y_b?4^|YA4%WW0VZd8C(ZgQzRI5(I^)=Ub`Y#MHc@nv0w-DaJAqsbEHDWG8Ia6ju zo-iyr*sq((gEwCC&^TYBWt4_@|81?=B-?#P6NMff(*^re zYqvDuO`K@`mjm_Jd;mW_tP`3$cS?R$jR1ZN09$YO%_iBqh5ftzSpMQQtxKFU=FYmP zeY^jph+g<4>YO;U^O>-NFLn~-RqlHvnZl2yd2A{Yc1G@Ga$d+Q&(f^tnPf+Z7serIU};17+2DU_f4Z z@GaPFut27d?!YiD+QP@)T=77cR9~MK@bd~pY%X(h%L={{OIb8IQmf-!xmZkm8A0Ga zQSWONI17_ru5wpHg3jI@i9D+_Y|pCqVuHJNdHUauTD=R$JcD2K_liQisqG$(sm=k9;L* z!L?*4B~ql7uioSX$zWJ?;q-SWXRFhz2Jt4%fOHA=Bwf|RzhwqdXGr78y$J)LR7&3T zE1WWz*>GPWKZ0%|@%6=fyx)5rzUpI;bCj>3RKzNG_1w$fIFCZ&UR0(7S?g}`&Pg$M zf`SLsz8wK82Vyj7;RyKmY{a8G{2BHG%w!^T|Njr!h9TO2LaP^_f22Q1=l$QiU84ao zHe_#{S6;qrC6w~7{y(hs-?-j?lbOfgH^E=XcSgnwW*eEz{_Z<_xN#0001NP)t-s|Ns9~ z#rXRE|M&d=0au&!`~QyF`q}dRnBDt}*!qXo`c{v z{Djr|@Adh0(D_%#_&mM$D6{kE_x{oE{l@J5@%H*?%=t~i_`ufYOPkAEn!pfkr2$fs z652Tz0001XNklqeeKN4RM4i{jKqmiC$?+xN>3Apn^ z0QfuZLym_5b<*QdmkHjHlj811{If)dl(Z2K0A+ekGtrFJb?g|wt#k#pV-#A~bK=OT ts8>{%cPtyC${m|1#B1A6#u!Q;umknL1chzTM$P~L002ovPDHLkV1lTfnu!1a literal 0 HcmV?d00001 diff --git a/caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..797d452e458972bab9d994556c8305db4c827017 GIT binary patch literal 406 zcmV;H0crk;P))>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed2d933e1120817fe9182483a228007b18ab6ae GIT binary patch literal 450 zcmV;z0X_bSP)iGWQ_5NJQ_~rNh*z)}eT%KUb z`7gNk0#AwF^#0T0?hIa^`~Ck;!}#m+_uT050aTR(J!bU#|IzRL%^UsMS#KsYnTF*!YeDOytlP4VhV?b} z%rz_<=#CPc)tU1MZTq~*2=8~iZ!lSa<{9b@2Jl;?IEV8)=fG217*|@)CCYgFze-x? zIFODUIA>nWKpE+bn~n7;-89sa>#DR>TSlqWk*!2hSN6D~Qb#VqbP~4Fk&m`@1$JGr zXPIdeRE&b2Thd#{MtDK$px*d3-Wx``>!oimf%|A-&-q*6KAH)e$3|6JV%HX{Hig)k suLT-RhftRq8b9;(V=235Wa|I=027H2wCDra;{X5v07*qoM6N<$f;9x^2LJ#7 literal 0 HcmV?d00001 diff --git a/caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..4cd7b0099ca80c806f8fe495613e8d6c69460d76 GIT binary patch literal 282 zcmV+#0p(^bcu7P-R4C8Q z&e;xxFbF_Vrezo%_kH*OKhshZ6BFpG-Y1e10`QXJKbND7AMQ&cMj60B5TNObaZxYybcN07*qoM6N<$g3m;S%K!iX literal 0 HcmV?d00001 diff --git a/caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fe730945a01f64a61e2235dbe3f45b08f7729182 GIT binary patch literal 462 zcmV;<0WtoGP)-}iV`2<;=$?g5M=KQbZ{F&YRNy7Nn@%_*5{gvDM0aKI4?ESmw z{NnZg)A0R`+4?NF_RZexyVB&^^ZvN!{I28tr{Vje;QNTz`dG&Jz0~Ek&f2;*Z7>B|cg}xYpxEFY+0YrKLF;^Q+-HreN0P{&i zK~zY`?b7ECf-n?@;d<&orQ*Q7KoR%4|C>{W^h6@&01>0SKS`dn{Q}GT%Qj_{PLZ_& zs`MFI#j-(>?bvdZ!8^xTwlY{qA)T4QLbY@j(!YJ7aXJervHy6HaG_2SB`6CC{He}f zHVw(fJWApwPq!6VY7r1w-Fs)@ox~N+q|w~e;JI~C4Vf^@d>Wvj=fl`^u9x9wd9 zR%3*Q+)t%S!MU_`id^@&Y{y7-r98lZX0?YrHlfmwb?#}^1b{8g&KzmkE(L>Z&)179 zp<)v6Y}pRl100G2FL_t(o!|l{-Q-VMg#&MKg7c{O0 z2wJImOS3Gy*Z2Qifdv~JYOp;v+U)a|nLoc7hNH;I$;lzDt$}rkaFw1mYK5_0Q(Sut zvbEloxON7$+HSOgC9Z8ltuC&0OSF!-mXv5caV>#bc3@hBPX@I$58-z}(ZZE!t-aOG zpjNkbau@>yEzH(5Yj4kZiMH32XI!4~gVXNnjAvRx;Sdg^`>2DpUEwoMhTs_st8pKG z(%SHyHdU&v%f36~uERh!bd`!T2dw;z6PrOTQ7Vt*#9F2uHlUVnb#ev_o^fh}Dzmq} zWtlk35}k=?xj28uO|5>>$yXadTUE@@IPpgH`gJ~Ro4>jd1IF|(+IX>8M4Ps{PNvmI zNj4D+XgN83gPt_Gm}`Ybv{;+&yu-C(Grdiahmo~BjG-l&mWM+{e5M1sm&=xduwgM9 z`8OEh`=F3r`^E{n_;%9weN{cf2%7=VzC@cYj+lg>+3|D|_1C@{hcU(DyQG_BvBWe? zvTv``=%b1zrol#=R`JB)>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..502f463a9bc882b461c96aadf492d1729e49e725 GIT binary patch literal 586 zcmV-Q0=4~#P)+}#`wDE{8-2Mebf5<{{PqV{TgVcv*r8?UZ3{-|G?_}T*&y;@cqf{ z{Q*~+qr%%p!1pS*_Uicl#q9lc(D`!D`LN62sNwq{oYw(Wmhk)k<@f$!$@ng~_5)Ru z0Z)trIA5^j{DIW^c+vT2%lW+2<(RtE2wR;4O@)Tm`Xr*?A(qYoM}7i5Yxw>D(&6ou zxz!_Xr~yNF+waPe00049Nkl*;a!v6h%{rlvIH#gW3s8p;bFr=l}mRqpW2h zw=OA%hdyL~z+UHOzl0eKhEr$YYOL-c-%Y<)=j?(bzDweB7{b+%_ypvm_cG{SvM=DK zhv{K@m>#Bw>2W$eUI#iU)Wdgs8Y3U+A$Gd&{+j)d)BmGKx+43U_!tik_YlN)>$7G! zhkE!s;%oku3;IwG3U^2kw?z+HM)jB{@zFhK8P#KMSytSthr+4!c(5c%+^UBn`0X*2 zy3(k600_CSZj?O$Qu%&$;|TGUJrptR(HzyIx>5E(2r{eA(<6t3e3I0B)7d6s7?Z5J zZ!rtKvA{MiEBm&KFtoifx>5P^Z=vl)95XJn()aS5%ad(s?4-=Tkis9IGu{`Fy8r+H07*qoM6N<$f20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0ec303439225b78712f49115768196d8d76f6790 GIT binary patch literal 862 zcmV-k1EKthP)20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..e9f5fea27c705180eb716271f41b582e76dcbd90 GIT binary patch literal 1674 zcmV;526g#~P){YQnis^a@{&-nmRmq)<&%Mztj67_#M}W?l>kYSliK<%xAp;0j{!}J0!o7b zE>q9${Lb$D&h7k=+4=!ek^n+`0zq>LL1O?lVyea53S5x`Nqqo2YyeuIrQrJj9XjOp z{;T5qbj3}&1vg1VK~#9!?b~^C5-}JC@Pyrv-6dSEqJqT}#j9#dJ@GzT@B8}x zU&J@bBI>f6w6en+CeI)3^kC*U?}X%OD8$Fd$H&LV$H&LV$H&LV#|K5~mLYf|VqzOc zkc7qL~0sOYuM{tG`rYEDV{DWY`Z8&)kW*hc2VkBuY+^Yx&92j&StN}Wp=LD zxoGxXw6f&8sB^u})h@b@z0RBeD`K7RMR9deyL(ZJu#39Z>rT)^>v}Khq8U-IbIvT> z?4pV9qGj=2)TNH3d)=De<+^w;>S7m_eFKTvzeaBeir45xY!^m!FmxnljbSS_3o=g( z->^wC9%qkR{kbGnW8MfFew_o9h3(r55Is`L$8KI@d+*%{=Nx+FXJ98L0PjFIu;rGnnfY zn1R5Qnp<{Jq0M1vX=X&F8gtLmcWv$1*M@4ZfF^9``()#hGTeKeP`1!iED ztNE(TN}M5}3Bbc*d=FIv`DNv&@|C6yYj{sSqUj5oo$#*0$7pu|Dd2TLI>t5%I zIa4Dvr(iayb+5x=j*Vum9&irk)xV1`t509lnPO0%skL8_1c#Xbamh(2@f?4yUI zhhuT5<#8RJhGz4%b$`PJwKPAudsm|at?u;*hGgnA zU1;9gnxVBC)wA(BsB`AW54N{|qmikJR*%x0c`{LGsSfa|NK61pYH(r-UQ4_JXd!Rsz)=k zL{GMc5{h138)fF5CzHEDM>+FqY)$pdN3}Ml+riTgJOLN0F*Vh?{9ESR{SVVg>*>=# zix;VJHPtvFFCRY$Ks*F;VX~%*r9F)W`PmPE9F!(&s#x07n2<}?S{(ygpXgX-&B&OM zONY&BRQ(#%0%jeQs?oJ4P!p*R98>qCy5p8w>_gpuh39NcOlp)(wOoz0sY-Qz55eB~ z7OC-fKBaD1sE3$l-6QgBJO!n?QOTza`!S_YK z_v-lm^7{VO^8Q@M_^8F)09Ki6%=s?2_5eupee(w1FB%aqSweusQ-T+CH0Xt{` zFjMvW{@C&TB)k25()nh~_yJ9coBRL(0oO@HK~z}7?bm5j;y@69;bvlHb2tf!$ReA~x{22wTq550 z?f?Hnw(;m3ip30;QzdV~7pi!wyMYhDtXW#cO7T>|f=bdFhu+F!zMZ2UFj;GUKX7tI z;hv3{q~!*pMj75WP_c}>6)IWvg5_yyg<9Op()eD1hWC19M@?_9_MHec{Z8n3FaF{8 z;u`Mw0ly(uE>*CgQYv{be6ab2LWhlaH1^iLIM{olnag$78^Fd}%dR7;JECQ+hmk|o z!u2&!3MqPfP5ChDSkFSH8F2WVOEf0(E_M(JL17G}Y+fg0_IuW%WQ zG(mG&u?|->YSdk0;8rc{yw2@2Z&GA}z{Wb91Ooz9VhA{b2DYE7RmG zjL}?eq#iX%3#k;JWMx_{^2nNax`xPhByFiDX+a7uTGU|otOvIAUy|dEKkXOm-`aWS z27pUzD{a)Ct<6p{{3)+lq@i`t@%>-wT4r?*S}k)58e09WZYP0{{R3FC5Sl00039P)t-s|Ns9~ z#rP?<_5oL$Q^olD{r_0T`27C={r>*`|Nj71npVa5OTzc(_WfbW_({R{p56NV{r*M2 z_xt?)2V0#0NsfV0u>{42ctGP(8vQj-Btk1n|O0ZD=YLwd&R{Ko41Gr9H= zY@z@@bOAMB5Ltl$E>bJJ{>JP30ZxkmI%?eW{k`b?Wy<&gOo;dS`~CR$Vwb@XWtR|N zi~t=w02?-0&j0TD{>bb6sNwsK*!p?V`RMQUl(*DVjk-9Cx+-z1KXab|Ka2oXhX5f% z`$|e!000AhNklrxs)5QTeTVRiEmz~MKK1WAjCw(c-JK6eox;2O)?`? zTG`AHia671e^vgmp!llKp|=5sVHk#C7=~epA~VAf-~%aPC=%Qw01h8mnSZ|p?hz91 z7p83F3%LVu9;S$tSI$C^%^yud1dfTM_6p2|+5Ejp$bd`GDvbR|xit>i!ZD&F>@CJrPmu*UjD&?DfZs=$@e3FQA(vNiU+$A*%a} z?`XcG2jDxJ_ZQ#Md`H{4Lpf6QBDp81_KWZ6Tk#yCy1)32zO#3<7>b`eT7UyYH1eGz z;O(rH$=QR*L%%ZcBpc=eGua?N55nD^K(8<#gl2+pN_j~b2MHs4#mcLmv%DkspS-3< zpI1F=^9siI0s-;IN_IrA;5xm~3?3!StX}pUv0vkxMaqm+zxrg7X7(I&*N~&dEd0kD z-FRV|g=|QuUsuh>-xCI}vD2imzYIOIdcCVV=$Bz@*u0+Bs<|L^)32nN*=wu3n%Ynw z@1|eLG>!8ruU1pFXUfb`j>(=Gy~?Rn4QJ-c3%3T|(Frd!bI`9u&zAnyFYTqlG#&J7 zAkD(jpw|oZLNiA>;>hgp1KX7-wxC~31II47gc zHcehD6Uxlf%+M^^uN5Wc*G%^;>D5qT{>=uxUhX%WJu^Z*(_Wq9y}npFO{Hhb>s6<9 zNi0pHXWFaVZnb)1+RS&F)xOv6&aeILcI)`k#0YE+?e)5&#r7J#c`3Z7x!LpTc01dx zrdC3{Z;joZ^KN&))zB_i)I9fWedoN>Zl-6_Iz+^G&*ak2jpF07*qoM6N<$f;w%0(f|Me literal 0 HcmV?d00001 diff --git a/caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/caregiver_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0467bf12aa4d28f374bb26596605a46dcbb3e7c8 GIT binary patch literal 1418 zcmV;51$Fv~P)q zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+ zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq z^={4hPQv)y=I|4n+?>7Fim=dxt1 z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf` zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_> z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3 zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62( zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;? zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-< z{s<&cCV_1`^TD^ia9!*mQDq& zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H literal 0 HcmV?d00001 diff --git a/caregiver_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/caregiver_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/caregiver_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/caregiver_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/caregiver_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/caregiver_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/caregiver_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/caregiver_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/caregiver_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/caregiver_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/caregiver_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/caregiver_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/caregiver_app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/caregiver_app/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/caregiver_app/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/caregiver_app/ios/Runner/Base.lproj/Main.storyboard b/caregiver_app/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/caregiver_app/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/caregiver_app/ios/Runner/Info.plist b/caregiver_app/ios/Runner/Info.plist new file mode 100644 index 0000000..1a80ed7 --- /dev/null +++ b/caregiver_app/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Caregiver App + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + caregiver_app + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/caregiver_app/ios/Runner/Runner-Bridging-Header.h b/caregiver_app/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/caregiver_app/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/caregiver_app/ios/Runner/SceneDelegate.swift b/caregiver_app/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/caregiver_app/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/caregiver_app/ios/RunnerTests/RunnerTests.swift b/caregiver_app/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/caregiver_app/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/caregiver_app/lib/main.dart b/caregiver_app/lib/main.dart new file mode 100644 index 0000000..3b18c6b --- /dev/null +++ b/caregiver_app/lib/main.dart @@ -0,0 +1,126 @@ +import 'dart:developer' as developer; + +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/material.dart'; + +import 'screens/active_alert_screen.dart'; +import 'screens/home_screen.dart'; +import 'services/caregiver_backend_service.dart'; +import 'services/push_notification_service.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Firebase.initializeApp(); + runApp(const CaregiverApp()); +} + +class CaregiverApp extends StatelessWidget { + const CaregiverApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Fall Guardian Caregiver', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF2D6A4F), + brightness: Brightness.light, + ), + useMaterial3: true, + ), + darkTheme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF2D6A4F), + brightness: Brightness.dark, + ), + useMaterial3: true, + ), + themeMode: ThemeMode.system, + home: const _AppRoot(), + ); + } +} + +class _AppRoot extends StatefulWidget { + const _AppRoot(); + + @override + State<_AppRoot> createState() => _AppRootState(); +} + +class _AppRootState extends State<_AppRoot> { + final _backend = CaregiverBackendService(); + late PushNotificationService _pushService; + + Map? _activeAlert; + bool _ready = false; + bool _linked = false; + + @override + void initState() { + super.initState(); + _pushService = PushNotificationService(onAlertReceived: _handleAlert); + _bootstrap(); + } + + Future _bootstrap() async { + try { + await _backend.ensureRegistered(); + await _pushService.initialize(); + final token = await _pushService.getFcmToken(); + if (token != null) { + try { + await _backend.registerPushToken(token); + developer.log('FCM token registered', name: '_AppRootState'); + } catch (e) { + // Not yet linked — token registration will fail if device is not caregiver type. + // This is fine; we'll retry after linking. + developer.log('FCM token registration skipped: $e', name: '_AppRootState'); + } + } + } catch (e) { + developer.log('Bootstrap error: $e', name: '_AppRootState'); + } finally { + if (mounted) setState(() => _ready = true); + } + } + + void _handleAlert(Map data) { + if (!mounted) return; + setState(() => _activeAlert = data); + } + + void _onLinked() { + setState(() => _linked = true); + // Re-register the FCM token now that we are linked + _pushService.getFcmToken().then((token) { + if (token != null) { + _backend.registerPushToken(token).catchError( + (e) => developer.log('Push token re-registration error: $e', name: '_AppRootState'), + ); + } + }); + } + + @override + Widget build(BuildContext context) { + if (!_ready) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + if (_activeAlert != null) { + return ActiveAlertScreen( + alertData: _activeAlert!, + onDismiss: () => setState(() => _activeAlert = null), + ); + } + + return CaregiverHomeScreen( + isLinked: _linked, + onLinked: _onLinked, + ); + } +} diff --git a/caregiver_app/lib/screens/active_alert_screen.dart b/caregiver_app/lib/screens/active_alert_screen.dart new file mode 100644 index 0000000..ad7c279 --- /dev/null +++ b/caregiver_app/lib/screens/active_alert_screen.dart @@ -0,0 +1,183 @@ +import 'dart:developer' as developer; + +import 'package:flutter/material.dart'; +import '../services/caregiver_backend_service.dart'; + +/// Full-screen alert shown when a fall notification is received. +class ActiveAlertScreen extends StatefulWidget { + const ActiveAlertScreen({ + super.key, + required this.alertData, + required this.onDismiss, + }); + + /// Data payload from the FCM message. + /// Keys: alertId, fallTimestamp, latitude, longitude. + final Map alertData; + final VoidCallback onDismiss; + + @override + State createState() => _ActiveAlertScreenState(); +} + +class _ActiveAlertScreenState extends State { + final _api = CaregiverBackendService(); + bool _acknowledging = false; + + String get _alertId => widget.alertData['alertId'] as String? ?? ''; + String get _fallTimestamp => widget.alertData['fallTimestamp'] as String? ?? ''; + String? get _latitude => widget.alertData['latitude'] as String?; + String? get _longitude => widget.alertData['longitude'] as String?; + + bool get _hasLocation => + _latitude != null && + _latitude!.isNotEmpty && + _longitude != null && + _longitude!.isNotEmpty; + + String get _formattedTime { + final dt = DateTime.tryParse(_fallTimestamp)?.toLocal(); + if (dt == null) return _fallTimestamp; + return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + } + + Future _acknowledge() async { + setState(() => _acknowledging = true); + try { + if (_alertId.isNotEmpty) { + await _api.acknowledgeFallAlert(_alertId); + } + } catch (e) { + developer.log('Failed to acknowledge alert: $e', name: '_ActiveAlertScreenState'); + } + if (mounted) widget.onDismiss(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFB00020), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon( + Icons.warning_rounded, + color: Colors.white, + size: 96, + ), + const SizedBox(height: 24), + const Text( + 'FALL DETECTED', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 32, + fontWeight: FontWeight.w900, + letterSpacing: 2, + ), + ), + const SizedBox(height: 12), + Text( + 'Detected at $_formattedTime', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70, fontSize: 18), + ), + const SizedBox(height: 32), + if (_hasLocation) ...[ + _InfoCard( + icon: Icons.location_on, + title: 'Location', + body: 'Lat: $_latitude\nLng: $_longitude', + ), + const SizedBox(height: 16), + ], + _InfoCard( + icon: Icons.info_outline, + title: 'Alert ID', + body: _alertId, + ), + const Spacer(), + FilledButton.icon( + onPressed: _acknowledging ? null : _acknowledge, + icon: _acknowledging + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Color(0xFFB00020), + ), + ) + : const Icon(Icons.check_circle_outline), + label: const Text('Acknowledge'), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: const Color(0xFFB00020), + padding: const EdgeInsets.symmetric(vertical: 18), + textStyle: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _InfoCard extends StatelessWidget { + const _InfoCard({ + required this.icon, + required this.title, + required this.body, + }); + + final IconData icon; + final String title; + final String body; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: Colors.white, size: 24), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 14, + ), + ), + const SizedBox(height: 4), + Text( + body, + style: const TextStyle(color: Colors.white70, fontSize: 13), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/caregiver_app/lib/screens/home_screen.dart b/caregiver_app/lib/screens/home_screen.dart new file mode 100644 index 0000000..1c1ffd9 --- /dev/null +++ b/caregiver_app/lib/screens/home_screen.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; +import 'link_screen.dart'; + +class CaregiverHomeScreen extends StatefulWidget { + const CaregiverHomeScreen({ + super.key, + required this.isLinked, + this.onLinked, + }); + + final bool isLinked; + final VoidCallback? onLinked; + + @override + State createState() => _CaregiverHomeScreenState(); +} + +class _CaregiverHomeScreenState extends State { + late bool _linked; + + @override + void initState() { + super.initState(); + _linked = widget.isLinked; + } + + void _onLinked() { + setState(() => _linked = true); + widget.onLinked?.call(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Linked successfully! You will now receive fall alerts.'), + ), + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text( + 'Fall Guardian Caregiver', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: _linked + ? [const Color(0xFF1B5E20), const Color(0xFF2E7D32)] + : [const Color(0xFF183153), const Color(0xFF284B63)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + Icon( + _linked ? Icons.volunteer_activism : Icons.link_off, + color: Colors.white, + size: 54, + ), + const SizedBox(height: 16), + Text( + _linked ? 'Monitoring Active' : 'Not Linked Yet', + style: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + _linked + ? 'You will receive push alerts if a fall is detected on the protected person\'s device.' + : 'Link with a protected person to start receiving fall alerts.', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70, fontSize: 14), + ), + ], + ), + ), + const SizedBox(height: 24), + if (!_linked) ...[ + FilledButton.icon( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => LinkScreen(onLinked: _onLinked), + ), + ), + icon: const Icon(Icons.add_link), + label: const Text('Link with Protected Person'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + const SizedBox(height: 16), + ], + _InfoCard( + title: _linked ? 'Status' : 'How it works', + body: _linked + ? 'Push notifications are active. Keep this app installed.' + : '1. Ask the protected person to generate a code in their Fall Guardian app.\n' + '2. Tap "Link" above and enter the code.\n' + '3. You\'ll receive push alerts on every detected fall.', + icon: _linked ? Icons.check_circle_outline : Icons.info_outline, + ), + const SizedBox(height: 16), + _InfoCard( + title: 'Important', + body: 'Keep notifications enabled for this app. Fall alerts are delivered as data-only messages — your phone must be on and connected.', + icon: Icons.notifications_active_outlined, + ), + const SizedBox(height: 24), + Text( + 'Separate apps keep the protected-person and caregiver flows cleaner, safer, and easier to maintain.', + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), + ), + ); + } +} + +class _InfoCard extends StatelessWidget { + const _InfoCard({ + required this.title, + required this.body, + required this.icon, + }); + + final String title; + final String body; + final IconData icon; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(18), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: cs.primary), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: cs.onSurface, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text( + body, + style: TextStyle(color: cs.onSurfaceVariant, height: 1.35), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/caregiver_app/lib/screens/link_screen.dart b/caregiver_app/lib/screens/link_screen.dart new file mode 100644 index 0000000..cc7c9b6 --- /dev/null +++ b/caregiver_app/lib/screens/link_screen.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import '../services/caregiver_backend_service.dart'; + +/// Screen where the caregiver enters the 8-character invite code generated +/// by the protected-person's device. +class LinkScreen extends StatefulWidget { + const LinkScreen({super.key, required this.onLinked}); + + final VoidCallback onLinked; + + @override + State createState() => _LinkScreenState(); +} + +class _LinkScreenState extends State { + final _codeController = TextEditingController(); + final _formKey = GlobalKey(); + final _api = CaregiverBackendService(); + bool _loading = false; + String? _errorMessage; + + @override + void dispose() { + _codeController.dispose(); + super.dispose(); + } + + Future _accept() async { + if (!_formKey.currentState!.validate()) return; + + setState(() { + _loading = true; + _errorMessage = null; + }); + + try { + await _api.acceptInvite(_codeController.text.trim().toUpperCase()); + if (mounted) widget.onLinked(); + } on CaregiverApiException catch (e) { + if (!mounted) return; + setState(() { + _errorMessage = e.statusCode == 404 + ? 'Code not found or expired. Ask for a new code.' + : 'Failed to accept invite (${e.statusCode}).'; + }); + } catch (_) { + if (!mounted) return; + setState(() => _errorMessage = 'Connection error. Check the backend.'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar(title: const Text('Link with Protected Person')), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF183153), Color(0xFF284B63)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(20), + ), + child: const Column( + children: [ + Icon(Icons.link, color: Colors.white, size: 48), + SizedBox(height: 16), + Text( + 'Enter Invite Code', + style: TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: 8), + Text( + 'Ask the protected person to generate a code in their Fall Guardian app.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white70, fontSize: 14), + ), + ], + ), + ), + const SizedBox(height: 32), + Form( + key: _formKey, + child: TextFormField( + controller: _codeController, + textCapitalization: TextCapitalization.characters, + maxLength: 8, + decoration: InputDecoration( + labelText: '8-character code', + prefixIcon: const Icon(Icons.vpn_key), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + style: const TextStyle( + fontSize: 24, + letterSpacing: 6, + fontWeight: FontWeight.bold, + ), + validator: (v) { + if (v == null || v.trim().length != 8) { + return 'Enter the full 8-character code'; + } + return null; + }, + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 8), + Text( + _errorMessage!, + style: TextStyle(color: cs.error), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _loading ? null : _accept, + icon: _loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.check), + label: const Text('Link as Caregiver'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ], + ), + ), + ); + } +} diff --git a/caregiver_app/lib/services/caregiver_backend_service.dart b/caregiver_app/lib/services/caregiver_backend_service.dart new file mode 100644 index 0000000..4e03055 --- /dev/null +++ b/caregiver_app/lib/services/caregiver_backend_service.dart @@ -0,0 +1,187 @@ +import 'dart:convert'; +import 'dart:developer' as developer; +import 'dart:io'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; + +class CaregiverBackendService { + CaregiverBackendService({ + FlutterSecureStorage? storage, + http.Client? client, + }) : _storage = storage ?? const FlutterSecureStorage(), + _client = client ?? http.Client(); + + static const _deviceIdKey = 'caregiver_device_id'; + static const _deviceTokenKey = 'caregiver_device_token'; + + final FlutterSecureStorage _storage; + final http.Client _client; + + String get _baseUrl { + const defined = String.fromEnvironment('BACKEND_BASE_URL'); + if (defined.isNotEmpty) { + return defined; + } + + if (Platform.isAndroid) { + return 'http://10.0.2.2:8002'; + } + + return 'http://127.0.0.1:8002'; + } + + Future ensureRegistered() async { + await _credentials(); + } + + Future acceptInvite(String code) async { + final credentials = await _credentials(); + final response = await _client.post( + Uri.parse('$_baseUrl/api/v1/invites/$code/accept'), + headers: _jsonHeaders(token: credentials.deviceToken), + ); + + if (!_isSuccess(response.statusCode)) { + throw CaregiverApiException( + 'Failed to accept invite', + statusCode: response.statusCode, + body: response.body, + ); + } + } + + Future acknowledgeFallAlert(String alertId) async { + final credentials = await _credentials(); + final response = await _client.post( + Uri.parse('$_baseUrl/api/v1/fall-alerts/$alertId/acknowledge'), + headers: _jsonHeaders(token: credentials.deviceToken), + ); + + if (!_isSuccess(response.statusCode)) { + throw CaregiverApiException( + 'Failed to acknowledge alert', + statusCode: response.statusCode, + body: response.body, + ); + } + } + + Future>> getCaregiverAlerts() async { + final credentials = await _credentials(); + final response = await _client.get( + Uri.parse('$_baseUrl/api/v1/caregiver/alerts'), + headers: _jsonHeaders(token: credentials.deviceToken), + ); + + if (!_isSuccess(response.statusCode)) { + throw CaregiverApiException( + 'Failed to fetch caregiver alerts', + statusCode: response.statusCode, + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is List) { + return decoded.cast>(); + } + // API Platform wraps collections in hydra:member + final wrapped = decoded as Map; + final members = wrapped['hydra:member'] as List? ?? []; + return members.cast>(); + } + + Future registerPushToken(String fcmToken) async { + final credentials = await _credentials(); + final response = await _client.post( + Uri.parse('$_baseUrl/api/v1/caregiver/push-token'), + headers: _jsonHeaders(token: credentials.deviceToken), + body: jsonEncode({'fcmToken': fcmToken}), + ); + + if (!_isSuccess(response.statusCode)) { + throw CaregiverApiException( + 'Failed to register push token', + statusCode: response.statusCode, + body: response.body, + ); + } + } + + Future<_CaregiverCredentials> _credentials() async { + final deviceId = await _storage.read(key: _deviceIdKey); + final deviceToken = await _storage.read(key: _deviceTokenKey); + + if (deviceId != null && + deviceId.isNotEmpty && + deviceToken != null && + deviceToken.isNotEmpty) { + return _CaregiverCredentials(deviceId: deviceId, deviceToken: deviceToken); + } + + final response = await _client.post( + Uri.parse('$_baseUrl/api/v1/devices/register'), + headers: _jsonHeaders(), + body: jsonEncode({ + 'platform': Platform.isAndroid ? 'android' : 'ios', + 'appVersion': '1.0.0', + 'deviceType': 'caregiver', + }), + ); + + if (!_isSuccess(response.statusCode)) { + throw CaregiverApiException( + 'Failed to register caregiver device', + statusCode: response.statusCode, + body: response.body, + ); + } + + final payload = jsonDecode(response.body) as Map; + final credentials = _CaregiverCredentials( + deviceId: payload['deviceId'] as String, + deviceToken: payload['deviceToken'] as String, + ); + + await _storage.write(key: _deviceIdKey, value: credentials.deviceId); + await _storage.write(key: _deviceTokenKey, value: credentials.deviceToken); + developer.log( + 'Registered caregiver device ${credentials.deviceId}', + name: 'CaregiverBackendService', + ); + + return credentials; + } + + Map _jsonHeaders({String? token}) { + return { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token', + }; + } + + bool _isSuccess(int statusCode) => statusCode >= 200 && statusCode < 300; +} + +class CaregiverApiException implements Exception { + CaregiverApiException(this.message, {this.statusCode, this.body}); + + final String message; + final int? statusCode; + final String? body; + + @override + String toString() => 'CaregiverApiException($message, $statusCode, $body)'; +} + +class _CaregiverCredentials { + const _CaregiverCredentials({ + required this.deviceId, + required this.deviceToken, + }); + + final String deviceId; + final String deviceToken; +} diff --git a/caregiver_app/lib/services/push_notification_service.dart b/caregiver_app/lib/services/push_notification_service.dart new file mode 100644 index 0000000..454295b --- /dev/null +++ b/caregiver_app/lib/services/push_notification_service.dart @@ -0,0 +1,72 @@ +import 'dart:developer' as developer; + +import 'package:firebase_messaging/firebase_messaging.dart'; + +/// Top-level handler required by FCM for background/terminated state. +/// Must be a top-level function (not a class method). +@pragma('vm:entry-point') +Future _onBackgroundMessage(RemoteMessage message) async { + developer.log( + 'FCM background message: ${message.messageId}', + name: 'PushNotificationService', + ); + // Actual UI is shown by the foreground handler when the app opens. + // For background/killed state, store the alert data for display on launch. +} + +class PushNotificationService { + PushNotificationService({required this.onAlertReceived}); + + /// Called whenever a fall alert data message arrives (foreground or opened-from-notification). + final void Function(Map data) onAlertReceived; + + FirebaseMessaging get _messaging => FirebaseMessaging.instance; + + Future initialize() async { + // Register background handler (must be called before any other Firebase setup) + FirebaseMessaging.onBackgroundMessage(_onBackgroundMessage); + + // Request permission (iOS requires explicit request; Android 13+ also requires it) + final settings = await _messaging.requestPermission( + alert: true, + badge: true, + sound: true, + ); + developer.log( + 'FCM permission: ${settings.authorizationStatus}', + name: 'PushNotificationService', + ); + + // Foreground messages + FirebaseMessaging.onMessage.listen((message) { + developer.log( + 'FCM foreground message: ${message.messageId}', + name: 'PushNotificationService', + ); + if (message.data.isNotEmpty) { + onAlertReceived(message.data); + } + }); + + // Opened from notification (background → foreground) + FirebaseMessaging.onMessageOpenedApp.listen((message) { + developer.log( + 'FCM opened from notification: ${message.messageId}', + name: 'PushNotificationService', + ); + if (message.data.isNotEmpty) { + onAlertReceived(message.data); + } + }); + + // Launched from terminated state via notification tap + final initialMessage = await _messaging.getInitialMessage(); + if (initialMessage != null && initialMessage.data.isNotEmpty) { + onAlertReceived(initialMessage.data); + } + } + + Future getFcmToken() async { + return _messaging.getToken(); + } +} diff --git a/caregiver_app/pubspec.lock b/caregiver_app/pubspec.lock new file mode 100644 index 0000000..a99ab09 --- /dev/null +++ b/caregiver_app/pubspec.lock @@ -0,0 +1,562 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11 + url: "https://pub.dev" + source: hosted + version: "1.3.59" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5" + url: "https://pub.dev" + source: hosted + version: "3.15.2" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37" + url: "https://pub.dev" + source: hosted + version: "2.24.1" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc" + url: "https://pub.dev" + source: hosted + version: "15.2.10" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754" + url: "https://pub.dev" + source: hosted + version: "4.6.10" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390" + url: "https://pub.dev" + source: hosted + version: "3.10.10" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.0.2" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.12.0-210.2.beta <4.0.0" + flutter: ">=3.38.4" diff --git a/caregiver_app/pubspec.yaml b/caregiver_app/pubspec.yaml new file mode 100644 index 0000000..6f46847 --- /dev/null +++ b/caregiver_app/pubspec.yaml @@ -0,0 +1,99 @@ +name: caregiver_app +description: "Fall Guardian caregiver mobile app." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.12.0-210.2.beta + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + + # Firebase push notifications + firebase_core: ^3.6.0 + firebase_messaging: ^15.1.3 + + # Backend API calls (same version as flutter_app) + http: ^1.2.1 + + # Secure local storage for device credentials + flutter_secure_storage: ^9.2.2 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/caregiver_app/test/widget_test.dart b/caregiver_app/test/widget_test.dart new file mode 100644 index 0000000..d5fc4dc --- /dev/null +++ b/caregiver_app/test/widget_test.dart @@ -0,0 +1,13 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:caregiver_app/main.dart'; + +void main() { + testWidgets('renders caregiver scaffold home', (tester) async { + await tester.pumpWidget(const CaregiverApp()); + + expect(find.text('Fall Guardian Caregiver'), findsOneWidget); + expect(find.text('Caregiver app scaffolded'), findsOneWidget); + expect(find.textContaining('separate caregiver client'), findsOneWidget); + }); +} From 642cb359b2a4f13f2ffd792e9464f4b0ef39305c Mon Sep 17 00:00:00 2001 From: Thomas Laure Date: Sun, 12 Apr 2026 21:29:47 +0200 Subject: [PATCH 5/7] feat(flutter): update contacts screen to caregiver invite flow Replaces the phone-number contacts form with an invite-code flow: the protected person generates a code (POST /api/v1/invites) and the screen displays it with a countdown. Renames AlertPhase.sendingSms to sendingAlert, adds createInvite() to BackendApiService, and updates localization strings and coordinator tests. Co-Authored-By: Claude Sonnet 4.6 --- flutter_app/lib/l10n/app_localizations.dart | 2 +- .../lib/l10n/app_localizations_en.dart | 25 ++- .../lib/l10n/app_localizations_fr.dart | 23 ++- flutter_app/lib/main.dart | 8 +- flutter_app/lib/screens/contacts_screen.dart | 171 ++++++++++++++++-- .../lib/services/alert_coordinator.dart | 8 +- .../lib/services/backend_api_service.dart | 18 ++ .../test/services/alert_coordinator_test.dart | 6 +- 8 files changed, 205 insertions(+), 56 deletions(-) diff --git a/flutter_app/lib/l10n/app_localizations.dart b/flutter_app/lib/l10n/app_localizations.dart index 1c2bd60..8dd61c0 100644 --- a/flutter_app/lib/l10n/app_localizations.dart +++ b/flutter_app/lib/l10n/app_localizations.dart @@ -54,7 +54,7 @@ abstract class AppLocalizations { String get fallAlertTitle; String get fallAlertBody; String get gettingLocation; - String get sendingSms; + String get sendingAlert; String get smsFailed; String alertSentCount(int count); String get cancelAlert; diff --git a/flutter_app/lib/l10n/app_localizations_en.dart b/flutter_app/lib/l10n/app_localizations_en.dart index 801476d..ade2e88 100644 --- a/flutter_app/lib/l10n/app_localizations_en.dart +++ b/flutter_app/lib/l10n/app_localizations_en.dart @@ -26,9 +26,9 @@ class AppLocalizationsEn extends AppLocalizations { String get homeStatusBody => 'Fall detection is active.\nA 30-second alert will appear if a fall is detected.'; @override - String get homeContactsTitle => 'Emergency Contacts'; + String get homeContactsTitle => 'Caregivers'; @override - String get homeContactsSubtitle => 'Manage who gets alerted'; + String get homeContactsSubtitle => 'Manage who should be alerted'; @override String get homeHistoryTitle => 'Fall History'; @override @@ -39,15 +39,13 @@ class AppLocalizationsEn extends AppLocalizations { // ── Contacts ───────────────────────────────────────────────────────────── @override - String get contactsScreenTitle => 'Emergency Contacts'; + String get contactsScreenTitle => 'Caregivers'; @override - String contactsRemoveTitle(String name) => - 'Remove $name from emergency contacts?'; + String contactsRemoveTitle(String name) => 'Remove $name from caregivers?'; @override String get contactsEmpty => 'No contacts yet'; @override - String get contactsEmptyHint => - 'Add family members to notify on fall detection.'; + String get contactsEmptyHint => 'Add caregivers to notify on fall detection.'; @override String get addContact => 'Add Contact'; @override @@ -70,16 +68,17 @@ class AppLocalizationsEn extends AppLocalizations { String get fallAlertTitle => 'Fall Detected!'; @override String get fallAlertBody => - 'Your emergency contacts will be notified unless you cancel.'; + 'Your caregivers will be notified unless you cancel.'; @override String get gettingLocation => 'Getting your location…'; @override - String get sendingSms => 'Sending SMS alerts…'; + String get sendingAlert => 'Sending alert…'; @override - String get smsFailed => '⚠️ SMS failed to send. Call your contacts manually!'; + String get smsFailed => + '⚠️ Alert delivery failed. Contact your caregivers manually.'; @override String alertSentCount(int count) => - 'Alert sent to $count contact${count == 1 ? '' : 's'}.'; + 'Alert sent to $count caregiver${count == 1 ? '' : 's'}.'; @override String get cancelAlert => "I'm OK — Cancel Alert"; @@ -98,13 +97,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get statusAlertSent => 'Alert Sent'; @override - String get statusAlertFailed => 'SMS Failed'; + String get statusAlertFailed => 'Alert Failed'; @override String get statusCancelled => 'Cancelled'; @override String get statusTimedOut => 'Timed Out'; @override - String notifiedLabel(String names) => 'Notified: $names'; + String notifiedLabel(String names) => 'Caregivers alerted: $names'; @override String locationLabel(String coords) => 'Location: $coords'; diff --git a/flutter_app/lib/l10n/app_localizations_fr.dart b/flutter_app/lib/l10n/app_localizations_fr.dart index 43766b5..6f684b7 100644 --- a/flutter_app/lib/l10n/app_localizations_fr.dart +++ b/flutter_app/lib/l10n/app_localizations_fr.dart @@ -26,9 +26,9 @@ class AppLocalizationsFr extends AppLocalizations { String get homeStatusBody => 'La détection de chutes est active.\n' 'Une alerte de 30 secondes apparaîtra si une chute est détectée.'; @override - String get homeContactsTitle => 'Contacts d\'urgence'; + String get homeContactsTitle => 'Aidants'; @override - String get homeContactsSubtitle => 'Gérer qui est alerté'; + String get homeContactsSubtitle => 'Gérer qui reçoit l’alerte'; @override String get homeHistoryTitle => 'Historique des chutes'; @override @@ -39,15 +39,14 @@ class AppLocalizationsFr extends AppLocalizations { // ── Contacts ───────────────────────────────────────────────────────────── @override - String get contactsScreenTitle => 'Contacts d\'urgence'; + String get contactsScreenTitle => 'Aidants'; @override - String contactsRemoveTitle(String name) => - 'Retirer $name des contacts d\'urgence ?'; + String contactsRemoveTitle(String name) => 'Retirer $name des aidants ?'; @override String get contactsEmpty => 'Aucun contact'; @override String get contactsEmptyHint => - 'Ajoutez des proches à prévenir en cas de chute détectée.'; + 'Ajoutez les aidants à prévenir en cas de chute détectée.'; @override String get addContact => 'Ajouter un contact'; @override @@ -70,17 +69,17 @@ class AppLocalizationsFr extends AppLocalizations { String get fallAlertTitle => 'Chute détectée !'; @override String get fallAlertBody => - 'Vos contacts d\'urgence seront prévenus sauf si vous annulez.'; + 'Vos aidants seront prévenus sauf si vous annulez.'; @override String get gettingLocation => 'Récupération de votre position…'; @override - String get sendingSms => 'Envoi des alertes SMS…'; + String get sendingAlert => "Envoi de l’alerte\u2026"; @override String get smsFailed => - '⚠️ Échec de l\'envoi du SMS. Appelez vos contacts manuellement !'; + '⚠️ Échec de l’envoi de l’alerte. Contactez vos aidants manuellement.'; @override String alertSentCount(int count) => - 'Alerte envoyée à $count contact${count == 1 ? '' : 's'}.'; + 'Alerte envoyée à $count aidant${count == 1 ? '' : 's'}.'; @override String get cancelAlert => 'Je vais bien — Annuler l\'alerte'; @@ -99,13 +98,13 @@ class AppLocalizationsFr extends AppLocalizations { @override String get statusAlertSent => 'Alerte envoyée'; @override - String get statusAlertFailed => 'SMS échoué'; + String get statusAlertFailed => 'Alerte échouée'; @override String get statusCancelled => 'Annulée'; @override String get statusTimedOut => 'Délai expiré'; @override - String notifiedLabel(String names) => 'Prévenus : $names'; + String notifiedLabel(String names) => 'Aidants prévenus : $names'; @override String locationLabel(String coords) => 'Position : $coords'; diff --git a/flutter_app/lib/main.dart b/flutter_app/lib/main.dart index b7ffac5..612959c 100644 --- a/flutter_app/lib/main.dart +++ b/flutter_app/lib/main.dart @@ -91,22 +91,16 @@ class _FallGuardianAppState extends State { @override void initState() { super.initState(); - // Register our two callback functions with the watch service. - // The service will call these whenever it receives an event from the - // native platform layer (Kotlin/Swift code on the watch side). _watchService.setFallDetectedCallback(_onFallDetected); _watchService.setCancelAlertCallback(_onAlertCancelled); - - // Ask for location permission early so a real alert is not the first time - // the user sees the GPS authorization sheet. WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited(_locationService.requestPermissionIfNeeded()); unawaited(_bootstrapBackend()); }); } Future _bootstrapBackend() async { try { + await _locationService.requestPermissionIfNeeded(); await _backendApi.ensureReady(); final contacts = await _contactsRepository.getAll(); await _backendApi.syncContacts(contacts); diff --git a/flutter_app/lib/screens/contacts_screen.dart b/flutter_app/lib/screens/contacts_screen.dart index 4835c10..52d3e0c 100644 --- a/flutter_app/lib/screens/contacts_screen.dart +++ b/flutter_app/lib/screens/contacts_screen.dart @@ -4,6 +4,7 @@ import 'package:uuid/uuid.dart'; import '../l10n/app_localizations.dart'; import '../models/contact.dart'; import '../repositories/contacts_repository.dart'; +import '../services/backend_api_service.dart'; class ContactsScreen extends StatefulWidget { const ContactsScreen({super.key}); @@ -14,9 +15,13 @@ class ContactsScreen extends StatefulWidget { class _ContactsScreenState extends State { final _repo = ContactsRepository(); + final _api = BackendApiService(); List _contacts = []; bool _loading = true; ContactsSyncState _syncState = ContactsSyncState.unknown; + String? _inviteCode; + DateTime? _inviteExpiresAt; + bool _creatingInvite = false; @override void initState() { @@ -55,6 +60,29 @@ class _ContactsScreenState extends State { } } + Future _createInvite() async { + setState(() => _creatingInvite = true); + try { + final data = await _api.createInvite(); + if (!mounted) return; + setState(() { + _inviteCode = data['code'] as String?; + _inviteExpiresAt = data['expiresAt'] != null + ? DateTime.tryParse(data['expiresAt'] as String) + : null; + }); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to create invite. Check backend.')), + ); + } + } finally { + if (mounted) setState(() => _creatingInvite = false); + } + } + Future _addContact() async { final l10n = AppLocalizations.of(context); final result = await showDialog( @@ -127,25 +155,136 @@ class _ContactsScreenState extends State { ), body: _loading ? const Center(child: CircularProgressIndicator()) - : _contacts.isEmpty - ? _EmptyState(l10n: l10n, onAdd: _addContact) - : Column( - children: [ - if (_syncState == ContactsSyncState.failed) - _SyncWarningBanner(l10n: l10n), - Expanded( - child: ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: _contacts.length, - itemBuilder: (_, i) => _ContactTile( - contact: _contacts[i], - onEdit: () => _editContact(_contacts[i]), - onDelete: () => _deleteContact(_contacts[i]), - ), + : Column( + children: [ + _InviteCaregiverSection( + inviteCode: _inviteCode, + expiresAt: _inviteExpiresAt, + loading: _creatingInvite, + onCreateInvite: _createInvite, + ), + if (_syncState == ContactsSyncState.failed) + _SyncWarningBanner(l10n: l10n), + if (_contacts.isEmpty) + Expanded(child: _EmptyState(l10n: l10n, onAdd: _addContact)) + else + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _contacts.length, + itemBuilder: (_, i) => _ContactTile( + contact: _contacts[i], + onEdit: () => _editContact(_contacts[i]), + onDelete: () => _deleteContact(_contacts[i]), ), ), - ], + ), + ], + ), + ); + } +} + +class _InviteCaregiverSection extends StatelessWidget { + const _InviteCaregiverSection({ + required this.inviteCode, + required this.expiresAt, + required this.loading, + required this.onCreateInvite, + }); + + final String? inviteCode; + final DateTime? expiresAt; + final bool loading; + final VoidCallback onCreateInvite; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Container( + margin: const EdgeInsets.fromLTRB(16, 16, 16, 0), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: cs.primaryContainer, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon(Icons.link, color: cs.onPrimaryContainer), + const SizedBox(width: 10), + Text( + 'Invite a Caregiver', + style: TextStyle( + color: cs.onPrimaryContainer, + fontWeight: FontWeight.w700, + fontSize: 15, + ), + ), + ], + ), + if (inviteCode != null) ...[ + const SizedBox(height: 12), + Text( + 'Share this code with your caregiver:', + style: TextStyle(color: cs.onPrimaryContainer, fontSize: 13), + ), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + inviteCode!, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + letterSpacing: 6, + color: cs.primary, + ), + ), + ], + ), + ), + if (expiresAt != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'Expires at ${expiresAt!.toLocal().toString().substring(11, 16)}', + style: TextStyle(color: cs.onPrimaryContainer, fontSize: 12), + textAlign: TextAlign.center, ), + ), + ] else ...[ + const SizedBox(height: 8), + Text( + 'Generate a one-time code (valid 30 min) for your caregiver to scan.', + style: TextStyle(color: cs.onPrimaryContainer, fontSize: 13), + ), + ], + const SizedBox(height: 12), + ElevatedButton.icon( + onPressed: loading ? null : onCreateInvite, + icon: loading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.add_link), + label: Text(inviteCode != null + ? 'Regenerate Code' + : 'Generate Invite Code'), + ), + ], + ), ); } } diff --git a/flutter_app/lib/services/alert_coordinator.dart b/flutter_app/lib/services/alert_coordinator.dart index 1fadc29..b11cd92 100644 --- a/flutter_app/lib/services/alert_coordinator.dart +++ b/flutter_app/lib/services/alert_coordinator.dart @@ -15,7 +15,7 @@ import '../repositories/fall_events_repository.dart'; enum AlertPhase { countdown, gettingLocation, - sendingSms, + sendingAlert, alertSent, alertFailed, timedOutNoSms, @@ -35,7 +35,7 @@ class AlertUiState { bool get isSending => phase == AlertPhase.gettingLocation || - phase == AlertPhase.sendingSms || + phase == AlertPhase.sendingAlert || phase == AlertPhase.alertSent || phase == AlertPhase.alertFailed || phase == AlertPhase.timedOutNoSms; @@ -182,8 +182,8 @@ class AlertCoordinator { _transition( timestamp, - AlertPhase.sendingSms, - statusMessage: l10n.sendingSms, + AlertPhase.sendingAlert, + statusMessage: l10n.sendingAlert, ); final contacts = await _contactsStore.getAll(); diff --git a/flutter_app/lib/services/backend_api_service.dart b/flutter_app/lib/services/backend_api_service.dart index 52b986a..5e20226 100644 --- a/flutter_app/lib/services/backend_api_service.dart +++ b/flutter_app/lib/services/backend_api_service.dart @@ -101,6 +101,24 @@ class BackendApiService implements AlertBackendGateway { return contacts.map((contact) => contact.name).toList(growable: false); } + Future> createInvite() async { + final credentials = await _credentials(); + final response = await _client.post( + Uri.parse('$_baseUrl/api/v1/invites'), + headers: _jsonHeaders(token: credentials.deviceToken), + ); + + if (!_isSuccess(response.statusCode)) { + throw BackendApiException( + 'Failed to create caregiver invite', + statusCode: response.statusCode, + body: response.body, + ); + } + + return jsonDecode(response.body) as Map; + } + @override Future cancelFallAlert({required String clientAlertId}) async { final token = await _store.read(_deviceTokenKey); diff --git a/flutter_app/test/services/alert_coordinator_test.dart b/flutter_app/test/services/alert_coordinator_test.dart index 8d83bb1..3845794 100644 --- a/flutter_app/test/services/alert_coordinator_test.dart +++ b/flutter_app/test/services/alert_coordinator_test.dart @@ -258,7 +258,7 @@ void main() { expect(states.map((state) => state.phase), [ AlertPhase.countdown, AlertPhase.gettingLocation, - AlertPhase.sendingSms, + AlertPhase.sendingAlert, AlertPhase.timedOutNoSms, ]); @@ -297,7 +297,7 @@ void main() { expect(states.map((state) => state.phase), [ AlertPhase.countdown, AlertPhase.gettingLocation, - AlertPhase.sendingSms, + AlertPhase.sendingAlert, AlertPhase.alertSent, ]); @@ -331,7 +331,7 @@ void main() { expect(states.map((state) => state.phase), [ AlertPhase.countdown, AlertPhase.gettingLocation, - AlertPhase.sendingSms, + AlertPhase.sendingAlert, AlertPhase.alertFailed, ]); From 04dffbdc471aa9bac1adbf33af316e120379a661 Mon Sep 17 00:00:00 2001 From: Thomas Laure Date: Sun, 12 Apr 2026 21:29:53 +0200 Subject: [PATCH 6/7] docs: update project status and next implementation steps Reflects completed caregiver model, push notification infrastructure, Behat test suite, and Flutter invite flow. Adds NEXT_IMPLEMENTATION_STEPS.md with the remaining work towards a fully functional end-to-end push notification path. Co-Authored-By: Claude Sonnet 4.6 --- CURRENT_STATUS.md | 4 +- NEXT_IMPLEMENTATION_STEPS.md | 223 +++++++++++++++++++++++++++++++++++ PROJECT_CONTEXT.md | 9 +- 3 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 NEXT_IMPLEMENTATION_STEPS.md diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index b634a8a..861aaee 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -6,7 +6,7 @@ This file tracks temporary or fast-changing project status that should not live - The Flutter app now submits timeout alerts to the backend, but real-device validation is still needed with a reachable backend URL instead of simulator localhost defaults. - The current backend implementation still follows an SMS/fake-SMS delivery shape; the target production direction is caregiver-app push notifications. -- The caregiver app does not exist yet, so the repository is still in a transition state between emergency contacts and linked caregivers. +- The caregiver app now exists only as a separate scaffold. The real caregiver workflow is still not implemented. - Android local SMS should be treated only as an optional fallback, not as the main production path. - Simulator iPhone/Apple Watch communication should not be treated as final product validation. - Flutter iOS simulator builds can fail due to stale generated `.packages` symlink content under `flutter_app/ios/Flutter/ephemeral/Packages/`. @@ -14,7 +14,7 @@ This file tracks temporary or fast-changing project status that should not live ## Product direction - Protected-person app: current Flutter phone app plus native watch apps. -- Caregiver app: planned dedicated mobile client for receiving and acknowledging alerts. +- Caregiver app: separate Flutter app scaffolded, but not yet connected to alert delivery. - Backend: source of truth for escalation, alert persistence, and future push notification delivery. - Primary delivery target: backend-owned push notifications. - Optional fallback: Android local SMS only when explicitly enabled. diff --git a/NEXT_IMPLEMENTATION_STEPS.md b/NEXT_IMPLEMENTATION_STEPS.md new file mode 100644 index 0000000..6837edf --- /dev/null +++ b/NEXT_IMPLEMENTATION_STEPS.md @@ -0,0 +1,223 @@ +# Fall Guardian — Remaining Implementation Work + +This file captures what still remains to implement after the repository was redirected toward the agreed target architecture: + +- protected-person app +- caregiver app +- backend-owned push notifications +- Android local SMS only as optional fallback + +## Current baseline + +What already exists: + +- protected-person app in [`flutter_app/`](/Users/thomaslaure/Documents/projects/fall_guardian/flutter_app) +- caregiver app scaffold in [`caregiver_app/`](/Users/thomaslaure/Documents/projects/fall_guardian/caregiver_app) +- Symfony backend in [`backend/`](/Users/thomaslaure/Documents/projects/fall_guardian/backend) +- current alert flow already reaches the backend +- docs now reflect the two-app direction + +What this means in practice: + +- the structural direction is correct +- the real caregiver product flow is not implemented yet + +## What still has to be implemented + +### 1. Backend domain model + +The backend is still built around: + +- `Device` +- `EmergencyContact` +- `FallAlert` +- `SmsAttempt` + +It needs to move toward: + +- `ProtectedPerson` +- `Caregiver` +- `CaregiverLink` +- `ProtectedDevice` +- `CaregiverDevice` +- `FallAlert` +- `PushAttempt` +- `AlertAcknowledgement` + +This is the main architectural gap. + +### 2. Caregiver identity and linking + +The product still needs a real linking model: + +- who the protected person is +- who the caregivers are +- how they are linked + +An explicit product decision is still needed for the linking flow: + +- invite code +- pairing token +- QR code +- email invitation +- phone-number-based linking + +Without this, the caregiver app cannot do real work. + +### 3. Push notification delivery + +The backend is still SMS-shaped. + +It still needs: + +- caregiver device token registration +- push provider integration + - FCM + - APNs path if needed via FCM or a backend abstraction +- push worker flow +- push attempt persistence +- delivery status model + +### 4. Caregiver app actual features + +The caregiver app still needs: + +- onboarding / login or linking flow +- device push token registration +- list of linked protected people +- active alert screen +- alert detail screen +- acknowledge action +- resolved / handled state +- history view + +At the moment it is only a shell. + +### 5. Protected-person app product changes + +The current `flutter_app` still behaves like a contact-based app. + +It still needs: + +- caregiver management flow instead of emergency contacts +- invite / link UI +- optional fallback settings for Android SMS +- wording cleanup in screens and repositories, not only localization +- history model aligned with alert delivery rather than SMS semantics everywhere + +### 6. Backend API redesign + +Current endpoints are still contact-oriented: + +- `/api/v1/emergency-contacts` +- `/api/v1/fall-alerts` + +Future backend endpoints likely need to include: + +- caregiver link / invite creation +- caregiver link acceptance +- caregiver device registration +- caregiver alert read model +- caregiver alert acknowledge endpoint + +### 7. Alert acknowledgement flow + +A real caregiver workflow needs: + +- caregiver receives alert +- caregiver opens it +- caregiver acknowledges it +- backend records acknowledgement +- protected-person side can reflect acknowledgement later if needed + +That loop does not exist yet. + +### 8. Real notification behavior + +The push flow still needs validation for: + +- app in foreground +- app in background +- locked phone +- killed app +- multiple caregiver devices +- multiple caregivers +- duplicate suppression +- late delivery handling + +### 9. Optional Android SMS fallback + +If Android SMS is kept: + +- it should be explicit +- Android-only +- not the main architecture +- ideally secondary fallback, not primary delivery + +The policy and code path for that are not finished. + +### 10. Backend migration path + +There is still a transition problem: + +- current backend schema is contact/SMS oriented +- target schema is caregiver/push oriented + +So a migration plan is still needed: + +- keep current contact model temporarily +- add caregiver model beside it +- migrate protected-person app from contacts to caregivers +- retire SMS-specific backend entities later + +### 11. Real-device validation + +The final architecture still needs real-device validation for: + +- Android phone + Galaxy Watch + backend +- iPhone + Apple Watch + backend +- caregiver app receiving push on real devices + +Until this is done, the architecture is not truly validated. + +## Recommended implementation order + +If continuing from here, the recommended order is: + +1. **Backend caregiver model** + - add protected person / caregiver / link entities + - keep current alert model temporarily + +2. **Caregiver app registration** + - device token registration + - placeholder linked-account state + +3. **Protected-person caregiver management** + - replace contact CRUD with caregiver linking flow + +4. **Push delivery** + - backend sends caregiver notifications instead of SMS as primary path + +5. **Caregiver alert UI** + - active alert screen + - acknowledge action + +6. **Optional Android SMS fallback** + - add only after the push path exists + +## Short summary + +The repo is now pointed in the correct direction structurally, but the core caregiver product still remains to be built: + +- caregiver identity +- caregiver linking +- push notifications +- caregiver app behavior +- acknowledgement flow +- backend domain migration + +Current state: + +- architecture direction set +- second app scaffolded +- production caregiver path not implemented yet diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index 0666231..41c2391 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -46,6 +46,7 @@ Repository layout: ```text fall_guardian/ ├── backend/ +├── caregiver_app/ ├── flutter_app/ ├── wear_os_app/ └── watchos_app/ @@ -53,7 +54,7 @@ fall_guardian/ ### Phone app -The current phone app is Flutter and is the protected-person app. It uses a cleaner ports/adapters structure around the alert workflow: +The current protected-person phone app is Flutter and uses a cleaner ports/adapters structure around the alert workflow: - `models/`: pure data structures - `repositories/`: persistence adapters @@ -82,6 +83,12 @@ Target direction: - use backend-owned push notifications as the primary caregiver alert channel - keep fake delivery in dev/test and optional Android local SMS fallback only where explicitly enabled +### Caregiver app + +`caregiver_app/` is the separate Flutter app for the caregiver experience. + +At this stage it is only a scaffold. It exists to preserve the two-app architecture while the backend and notification model catch up. + ### Native watch and phone bridges Android/Wear OS: From d94f7e697c364aa22253d2a65d2b8d521fe822f6 Mon Sep 17 00:00:00 2001 From: Thomas Laure Date: Sun, 12 Apr 2026 23:03:23 +0200 Subject: [PATCH 7/7] feat(backend): add fake push store and /debug/fake-push endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the FakeSmsStore pattern for push: FakePushStore persists each dispatched push to fake_push_inbox.jsonl, FakePushGateway writes to it instead of logging, and DebugFakePushController exposes GET /debug/fake-push (non-prod only). Adds an end-to-end Behat scenario that verifies the full chain: invite → link → FCM token → fall alert → push dispatched. Also clears both stores before each Behat scenario. Co-Authored-By: Claude Sonnet 4.6 --- backend/config/services.yaml | 9 ++ backend/features/fall_alert.feature | 27 ++++ .../Infrastructure/Push/FakePushGateway.php | 13 +- .../src/Infrastructure/Push/FakePushStore.php | 143 ++++++++++++++++++ .../UI/Controller/DebugFakePushController.php | 32 ++++ backend/tests/Behat/ApiContext.php | 37 ++++- 6 files changed, 248 insertions(+), 13 deletions(-) create mode 100644 backend/src/Infrastructure/Push/FakePushStore.php create mode 100644 backend/src/UI/Controller/DebugFakePushController.php diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 4a6b472..65ad0ac 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -70,6 +70,15 @@ services: App\Domain\Caregiver\Port\CaregiverPushTokenRepositoryInterface: '@App\Infrastructure\Persistence\DoctrineCaregiverPushTokenRepository' App\Domain\Contact\Port\EmergencyContactRepositoryInterface: '@App\Infrastructure\Persistence\DoctrineEmergencyContactRepository' + App\Infrastructure\Push\FakePushStore: + arguments: + $projectDir: '%kernel.project_dir%' + $shareDir: '%app.share_dir%' + + App\UI\Controller\DebugFakePushController: + arguments: + $appEnv: '%kernel.environment%' + App\Infrastructure\Push\FcmPushGateway: arguments: $projectId: '%app.fcm.project_id%' diff --git a/backend/features/fall_alert.feature b/backend/features/fall_alert.feature index 9eebbe4..7083e77 100644 --- a/backend/features/fall_alert.feature +++ b/backend/features/fall_alert.feature @@ -59,6 +59,33 @@ Feature: Fall alert management Then the response status code is 201 And the response JSON field "status" equals "cancelled" + Scenario: Fall alert dispatches a push notification to a linked caregiver + Given I register a protected person device + And I register a caregiver device + And I am authenticated as the protected person + When I send a POST request to "/api/v1/invites" + Then the response status code is 201 + And I store the response JSON field "code" as "inviteCode" + And I am authenticated as the caregiver + When I send a POST request to "/api/v1/invites/{inviteCode}/accept" + Then the response status code is 204 + When I send a POST request to "/api/v1/caregiver/push-token" with: + """ + {"fcmToken": "test-fcm-token-e2e-001"} + """ + Then the response status code is 204 + And I am authenticated as the protected person + When I send a POST request to "/api/v1/fall-alerts" with: + """ + { + "clientAlertId": "fa-behat-push-e2e", + "fallTimestamp": "2025-01-01T12:00:00+00:00", + "locale": "en" + } + """ + Then the response status code is 201 + And the fake push inbox contains 1 messages + Scenario: Creating a fall alert without authentication is rejected When I send a POST request to "/api/v1/fall-alerts" with: """ diff --git a/backend/src/Infrastructure/Push/FakePushGateway.php b/backend/src/Infrastructure/Push/FakePushGateway.php index 9575327..8048421 100644 --- a/backend/src/Infrastructure/Push/FakePushGateway.php +++ b/backend/src/Infrastructure/Push/FakePushGateway.php @@ -5,7 +5,6 @@ namespace App\Infrastructure\Push; use App\Domain\Push\Port\PushGatewayInterface; -use Psr\Log\LoggerInterface; use function sprintf; @@ -13,7 +12,7 @@ final readonly class FakePushGateway implements PushGatewayInterface { - public function __construct(private LoggerInterface $logger) + public function __construct(private FakePushStore $store) { } @@ -25,15 +24,7 @@ public function getProviderName(): string public function send(string $fcmToken, string $alertId, string $fallTimestamp, ?float $latitude, ?float $longitude): array { $providerMessageId = sprintf('fake-push-%s', Uuid::v7()->toRfc4122()); - - $this->logger->info('FakePushGateway: push sent', [ - 'providerMessageId' => $providerMessageId, - 'fcmToken' => substr($fcmToken, 0, 12) . '...', - 'alertId' => $alertId, - 'fallTimestamp' => $fallTimestamp, - 'latitude' => $latitude, - 'longitude' => $longitude, - ]); + $this->store->append($providerMessageId, $fcmToken, $alertId, $fallTimestamp, $latitude, $longitude); return [ 'providerMessageId' => $providerMessageId, diff --git a/backend/src/Infrastructure/Push/FakePushStore.php b/backend/src/Infrastructure/Push/FakePushStore.php new file mode 100644 index 0000000..ff0de9a --- /dev/null +++ b/backend/src/Infrastructure/Push/FakePushStore.php @@ -0,0 +1,143 @@ + + */ + public function all(): array + { + $path = $this->path(); + + if (!file_exists($path)) { + return []; + } + + $entries = []; + $contents = file_get_contents($path); + + if (false === $contents || '' === $contents) { + return []; + } + + foreach (explode("\n", trim($contents)) as $line) { + if ('' === $line) { + continue; + } + + $decoded = json_decode($line, true, 512, JSON_THROW_ON_ERROR); + + if (!is_array($decoded)) { + continue; + } + + $providerMessageId = $decoded['providerMessageId'] ?? null; + $fcmToken = $decoded['fcmToken'] ?? null; + $alertId = $decoded['alertId'] ?? null; + $fallTimestamp = $decoded['fallTimestamp'] ?? null; + $createdAt = $decoded['createdAt'] ?? null; + + if ( + is_string($providerMessageId) + && is_string($fcmToken) + && is_string($alertId) + && is_string($fallTimestamp) + && is_string($createdAt) + ) { + $latitude = isset($decoded['latitude']) && is_string($decoded['latitude']) ? $decoded['latitude'] : null; + $longitude = isset($decoded['longitude']) && is_string($decoded['longitude']) ? $decoded['longitude'] : null; + + $entries[] = [ + 'providerMessageId' => $providerMessageId, + 'fcmToken' => $fcmToken, + 'alertId' => $alertId, + 'fallTimestamp' => $fallTimestamp, + 'latitude' => $latitude, + 'longitude' => $longitude, + 'createdAt' => $createdAt, + ]; + } + } + + return $entries; + } + + public function clear(): void + { + $path = $this->path(); + + if (file_exists($path)) { + file_put_contents($path, ''); + } + } + + public function append(string $providerMessageId, string $fcmToken, string $alertId, string $fallTimestamp, ?float $latitude, ?float $longitude): void + { + $path = $this->path(); + $directory = dirname($path); + + if (!is_dir($directory)) { + mkdir($directory, 0777, true); + } + + $entry = [ + 'providerMessageId' => $providerMessageId, + 'fcmToken' => $fcmToken, + 'alertId' => $alertId, + 'fallTimestamp' => $fallTimestamp, + 'latitude' => null !== $latitude ? (string) $latitude : null, + 'longitude' => null !== $longitude ? (string) $longitude : null, + 'createdAt' => new DateTimeImmutable()->format(DATE_ATOM), + ]; + + file_put_contents( + $path, + sprintf("%s\n", json_encode($entry, JSON_THROW_ON_ERROR)), + FILE_APPEND, + ); + } + + private function path(): string + { + return sprintf( + '%s/%s/fake_push_inbox.jsonl', + $this->projectDir, + rtrim($this->shareDir, '/'), + ); + } +} diff --git a/backend/src/UI/Controller/DebugFakePushController.php b/backend/src/UI/Controller/DebugFakePushController.php new file mode 100644 index 0000000..f7c76e3 --- /dev/null +++ b/backend/src/UI/Controller/DebugFakePushController.php @@ -0,0 +1,32 @@ +appEnv) { + throw new NotFoundHttpException(); + } + + return new JsonResponse([ + 'provider' => 'fake', + 'messages' => $this->store->all(), + ]); + } +} diff --git a/backend/tests/Behat/ApiContext.php b/backend/tests/Behat/ApiContext.php index b98b2c7..2532492 100644 --- a/backend/tests/Behat/ApiContext.php +++ b/backend/tests/Behat/ApiContext.php @@ -4,12 +4,16 @@ namespace App\Tests\Behat; +use App\Infrastructure\Push\FakePushStore; +use App\Infrastructure\Sms\FakeSmsStore; + use function array_key_exists; use Behat\Behat\Context\Context; use Behat\Behat\Hook\Scope\BeforeScenarioScope; use Behat\Gherkin\Node\PyStringNode; +use function count; use function is_array; use function is_scalar; use function json_decode; @@ -43,8 +47,11 @@ final class ApiContext implements Context /** @var array|null */ private ?array $lastResponseData = null; - public function __construct(KernelInterface $kernel) - { + public function __construct( + KernelInterface $kernel, + private readonly FakeSmsStore $smsStore, + private readonly FakePushStore $pushStore, + ) { $this->client = new KernelBrowser($kernel); $this->client->disableReboot(); } @@ -61,6 +68,8 @@ public function resetScenarioState(BeforeScenarioScope $scope): void $this->stored = []; $this->lastStatusCode = 0; $this->lastResponseData = null; + $this->smsStore->clear(); + $this->pushStore->clear(); } // ─── Given ───────────────────────────────────────────────────────────────── @@ -235,6 +244,30 @@ public function theResponseIsAnEmptyCollection(): void } } + /** + * @Then the fake SMS inbox contains :count messages + */ + public function theFakeSmsInboxContainsMessages(int $count): void + { + $actual = count($this->smsStore->all()); + + if ($actual !== $count) { + throw new RuntimeException(sprintf('Expected %d SMS message(s) but found %d.', $count, $actual)); + } + } + + /** + * @Then the fake push inbox contains :count messages + */ + public function theFakePushInboxContainsMessages(int $count): void + { + $actual = count($this->pushStore->all()); + + if ($actual !== $count) { + throw new RuntimeException(sprintf('Expected %d push message(s) but found %d.', $count, $actual)); + } + } + /** * @Then I store the response JSON field :field as :key */