Compare commits

..

10 Commits

Author SHA1 Message Date
Rijad Zuzo d80486b25e Add maintainer onboarding documentation
Flutter CI / analyze_and_test (push) Has been cancelled
2026-07-03 12:44:15 +02:00
Rijad Zuzo 5d80af375e Add LLM digest setup and share timestamp context 2026-05-19 19:17:35 +02:00
Rijad Zuzo 9d2da0c600 Improve share intake diagnostics 2026-05-19 11:26:23 +02:00
Rijad Zuzo 0bfe67b1bf Clean up LLM digest provider errors 2026-05-19 11:26:14 +02:00
Rijad Zuzo e13d8b76cd Improve LLM provider configuration 2026-05-19 11:14:23 +02:00
Rijad Zuzo d81215db73 Add iOS save action extension 2026-05-18 20:50:23 +02:00
Rijad Zuzo 42a59e959f Refine backendless share intake 2026-05-18 20:33:54 +02:00
Rijad Zuzo f655adfbea feat: add local-first private AI digest workflow
Migrate app code into canonical feature slices, add phone-only AI digest scheduling and review, wire local notification/background task support, and cover the flow with tests.
2026-05-17 00:17:20 +02:00
Rijad Zuzo dab50abf0e docs: add project orientation notes 2026-05-17 00:17:03 +02:00
Rijad Zuzo 6c4a139448 Add deferred tasks backlog doc 2026-02-23 22:45:10 +01:00
255 changed files with 31193 additions and 15919 deletions
+8
View File
@@ -40,6 +40,7 @@ app.*.map.json
# Android
# -----------------------------
android/.gradle/
android/local.properties
android/app/debug/
android/app/profile/
android/app/release/
@@ -49,8 +50,15 @@ android/app/release/
# -----------------------------
ios/Flutter/ephemeral/
ios/Flutter/.last_build_id
ios/Flutter/Flutter.podspec
ios/Flutter/Generated.xcconfig
ios/Flutter/flutter_export_environment.sh
ios/Flutter/App.framework/
ios/Flutter/Flutter.framework/
ios/Pods/
ios/.symlinks/
ios/Runner/GeneratedPluginRegistrant.h
ios/Runner/GeneratedPluginRegistrant.m
ios/Runner.xcworkspace/xcuserdata/
ios/Runner.xcodeproj/xcuserdata/
+128
View File
@@ -0,0 +1,128 @@
# AGENTS.md
This file is the fastest orientation doc for future coding agents working in
this repository.
## Project Summary
`relationship_saver` is a Flutter app for capturing and maintaining
relationship context locally. The app already supports:
- people profiles with aliases, notes, tags, facts, important dates, and
preference signals
- moments, ideas, reminders, and dashboard tasks
- Android/in-app share-intake flows with inbox-first review for ambiguous
content
- local notification scheduling for reminders
- fake-backend auth/sync flows and REST/OpenAPI scaffolding for later backend
work
The current product posture is local-first and prototype-oriented, not a fully
server-backed production app.
## Canonical Code Paths
Start here before changing behavior:
- `lib/main.dart`: app entry
- `lib/app/relationship_saver_app.dart`: root widget, auth gate, global
listeners
- `lib/app/presentation/app_shell.dart`: responsive shell and navigation
- `lib/app/data/relationship_repository.dart`: aggregate local-first write
boundary
- `lib/features/sync/data/sync_queue_repository.dart`: queued mutation store
- `lib/integrations/backend/backend_gateway_provider.dart`: fake vs REST
gateway selection
- `lib/core/config/app_config.dart`: runtime flags
## Architecture Reality
The repo is in a vertical-slice migration.
- Canonical structure is `lib/app`, `lib/features`, `lib/core`,
`lib/integrations`
- Many older files at slice roots are compatibility exports
- Prefer editing canonical files under:
- `presentation/`
- `application/`
- `domain/`
- `data/`
- Avoid expanding legacy compatibility files unless the task is specifically
about keeping old imports working
Useful orientation docs:
- `lib/README.md`
- `lib/app/README.md`
- `lib/features/README.md`
- `lib/core/README.md`
- slice READMEs inside `lib/features/*`
## Main Product Slices
- `people`: primary relationship workspace and richest data model
- `share_intake`: normalized shared payloads, review flows, inbox routing
- `sync`: queued changes, push/pull orchestration, rejection repair
- `reminders`: local reminder rules and notification scheduling
- `dashboard`: overview metrics and graph preview/explorer
- `settings`: trust/privacy copy, AI config, share simulation, environment
diagnostics
- `auth`: sign-in UI and session lifecycle
## Storage And Data Flow
- Local state is centered on `LocalDataState` in `lib/app/state/`
- `LocalRepository` persists a mostly single aggregate snapshot
- Default local store is Hive; shared preferences remains as legacy fallback
and migration path
- Sync queue state is persisted separately through the sync store abstraction
- The app is intentionally offline-safe in fake-backend mode
## Runtime Flags
Defined in `lib/core/config/app_config.dart`:
- `USE_FAKE_BACKEND=true` by default
- `BACKEND_BASE_URL`
- `USE_HIVE_LOCAL_DB=true` by default
- `ENABLE_BACKGROUND_SYNC=true` by default
- `BACKGROUND_SYNC_INTERVAL_SECONDS=180` by default
- `ENABLE_LOCAL_NOTIFICATIONS=true` by default
- `ENABLE_WHATSAPP_SHARE_INTAKE=true` by default
If behavior seems surprising, check the active `--dart-define` values first.
## Backend Boundary
- Transport contract is documented in `docs/api/openapi.yaml`
- Architectural rationale lives in
`docs/ADR/0002-backend-protocol-rest-openapi.md`
- `BackendGatewayFake` is still the default development path
- `BackendGatewayRest` is the real transport boundary when fake mode is off
## Known Limitations
- iOS share extension work is deferred; see `docs/open-tasks.md`
- Native share-entry support is stronger on Android than iOS
- Sync scaffolding exists, but not all newer local entities are mapped to the
backend protocol yet
- The repo currently contains a lot of in-flight migration work and
compatibility exports
## Working Guidance
- Read the relevant slice README before changing a feature
- Treat existing uncommitted changes as user work unless you created them
- Prefer canonical slice files over compatibility exports
- Keep docs aligned with the verified code, not with older plans
- When changing architecture-level behavior, update the nearest README or this
file if it affects future orientation
## Useful Commands
```bash
flutter pub get
flutter test
flutter analyze
dart run build_runner build --delete-conflicting-outputs
```
+131 -11
View File
@@ -1,17 +1,137 @@
# relationship_saver
# Relationship Saver
A new Flutter project.
`relationship_saver` is a local-first Flutter app for managing relationship
context: people, moments, ideas, reminders, share-intake, and lightweight
signals that can later support sync and AI-assisted workflows.
## Getting Started
The app currently runs as a prototype-friendly single-client build:
This project is a starting point for a Flutter application.
- Flutter + Material UI
- Riverpod for app state
- Hive-backed local persistence by default
- fake backend enabled by default for offline-safe development
- REST/OpenAPI scaffolding for future auth + sync work
- local notifications for reminders
- Android text-share entry and in-app share simulation tools
A few resources to get you started if this is your first Flutter project:
## Current Architecture
- [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)
The codebase is mid-migration from an older horizontal structure to a
vertical-slice layout.
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.
- `lib/app/`: app bootstrap, shell navigation, aggregate persistence, shared
app state
- `lib/features/`: product slices such as `people`, `share_intake`, `sync`,
`reminders`, `dashboard`, and `settings`
- `lib/core/`: cross-cutting primitives like config, auth, networking, theme,
and shared presentation helpers
- `lib/integrations/backend/`: backend-facing gateway contracts, REST/fake
implementations, DTOs, and mappers
Many older root-level files under `lib/features/*` still exist as compatibility
exports. New work should generally target the canonical files in `app/`,
`core/`, or slice subfolders such as `presentation/`, `application/`,
`domain/`, and `data/`.
## Key Runtime Behavior
- Entry point: `lib/main.dart`
- Root widget: `lib/app/relationship_saver_app.dart`
- Authenticated shell: `lib/app/presentation/app_shell.dart`
- Local-first aggregate repository:
`lib/app/data/relationship_repository.dart`
- Queued sync persistence:
`lib/features/sync/data/sync_queue_repository.dart`
Notable build-time flags live in `lib/core/config/app_config.dart`:
- `USE_FAKE_BACKEND` defaults to `true`
- `BACKEND_BASE_URL`
- `USE_HIVE_LOCAL_DB`
- `ENABLE_BACKGROUND_SYNC`
- `BACKGROUND_SYNC_INTERVAL_SECONDS`
- `ENABLE_LOCAL_NOTIFICATIONS`
- `ENABLE_WHATSAPP_SHARE_INTAKE`
- `SENTRY_DSN` defaults to the Bugsink project DSN
- `SENTRY_ENVIRONMENT`
- `SENTRY_RELEASE`
## Run
Install packages:
```bash
flutter pub get
```
Run with the default fake backend:
```bash
flutter run
```
Run against a REST backend:
```bash
flutter run \
--dart-define=USE_FAKE_BACKEND=false \
--dart-define=BACKEND_BASE_URL=https://api.example.com
```
Build a release with explicit Bugsink/Sentry metadata:
```bash
flutter build apk --release \
--dart-define=SENTRY_ENVIRONMENT=production \
--dart-define=SENTRY_RELEASE=relationship_saver@1.0.0+1
```
Run tests:
```bash
flutter test
```
Generate code:
```bash
dart run build_runner build --delete-conflicting-outputs
```
## Current Product Areas
- `Dashboard`: overview cards and relationship graph preview/explorer
- `People`: primary workspace for profiles, notes, facts, dates, tags, and
preference signals
- `Moments`: interaction/capture history
- `Signals`: lightweight prompts derived from local relationship context
- `Ideas`: follow-up and gift/activity/place ideas
- `Reminders`: reminder rules plus local notification scheduling
- `Sync`: queued change diagnostics and repair surface
- `Settings`: environment flags, trust/privacy copy, AI config, share
simulation, and notification permission actions
## Docs
- [Documentation Index](docs/README.md)
- [Onboarding](docs/ONBOARDING.md)
- [Architecture](docs/ARCHITECTURE.md)
- [Data Model](docs/DATA_MODEL.md)
- [Feature Map](docs/FEATURE_MAP.md)
- [Implementation Playbook](docs/IMPLEMENTATION_PLAYBOOK.md)
- [Setup](docs/SETUP.md)
- [Progress Log](docs/progress.md)
- [Open Tasks](docs/open-tasks.md)
- [Client Audit](docs/client-audit-relationship-app.md)
- [Client Implementation Plan](docs/client-implementation-plan.md)
- [Client Iteration Summary](docs/client-iteration-summary.md)
- [Backend ADR](docs/ADR/0002-backend-protocol-rest-openapi.md)
- [OpenAPI Contract](docs/api/openapi.yaml)
- [Contributor Notes](AGENTS.md)
## Known Gaps
- iOS share-sheet integration is still deferred; see `docs/open-tasks.md`
- sync/backend contracts exist, but the product remains primarily local-first
- the repository layer still persists one aggregate snapshot for most local
state while the slice migration continues
+24 -2
View File
@@ -1,12 +1,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:label="relationship_saver"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:launchMode="singleTask"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
@@ -24,12 +27,31 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="text/*"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="text/*"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
</intent-filter>
</receiver>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
@@ -0,0 +1,64 @@
# ADR 0003: Backendless Local LLM Pipeline
## Status
Accepted
## Context
The product goal is a phone-first relationship memory app. The user shares
messages and text snippets from messaging apps, resolves uncertain identity
matches over time, and receives private extraction plus useful suggestions.
The app is moving away from a server-backed source of truth. Backend sync and
the fake backend should not create or overwrite relationship data unless a
developer explicitly enables REST sync for transport work.
iOS does not provide a reliable always-running background service. Background
work must be treated as opportunistic: run on foreground/app resume, after share
intake, through user-initiated actions, and through scheduled background windows
when the OS grants time.
## Decision
Use a backendless local-first pipeline:
1. Share intake stores raw shared payloads locally.
2. Strong identity matches attach directly to an existing person.
3. Ambiguous, conflicting, or low-information shares stay in `Share Inbox`.
4. Nightly extraction processes only new/resolved share batches.
5. Weekly/on-demand recommendations use compact profile summaries plus prior
suggestion history to avoid repeats.
6. Internet-grounded recommendations are generated only during the weekly or
manual recommendation phase, not during nightly extraction.
7. Fake backend sync is inert by default and must not seed local relationship
records.
## LLM Cost Controls
- Batch new shares into one extraction request per run.
- Use source fingerprints and content hashes to skip repeated extraction.
- Store extraction run fingerprints, completed timestamps, and failures.
- Send compact person tokens and normalized facts instead of names and raw
history when possible.
- Keep grounded shopping/event prompts separate from nightly extraction because
web search is more expensive and time-sensitive.
- Include accepted/dismissed/pending suggestion fingerprints in weekly prompts
so repeated concerts, shops, or gifts are suppressed.
## iOS Background Policy
- Treat scheduled nightly and weekly work as best-effort.
- Always offer manual `Run Now` actions.
- Prefer local notifications after work completes.
- Do not require APNs or a server daemon for core behavior.
- Keep work units short enough to survive iOS background expiration.
## Consequences
- The app remains useful without any backend.
- Share capture and profile building are durable across weeks/months of gradual
user review.
- Suggestions may run later than the configured wall-clock time on iOS.
- REST sync can still exist as a developer/integration path, but it is not part
of the primary product loop.
+132
View File
@@ -0,0 +1,132 @@
# AI Digest
The AI digest is a private, review-first recommendation flow. It should help the
user maintain relationships without leaking identifying relationship data.
## Main Files
Core LLM:
- `lib/core/llm/llm_config.dart`
- `lib/core/llm/llm_service.dart`
- `lib/core/llm/llm_diagnostics_log.dart`
- `lib/core/llm/ollama/`
Digest:
- `lib/features/ai_digest/application/anonymized_llm_context_builder.dart`
- `lib/features/ai_digest/application/llm_digest_orchestrator.dart`
- `lib/features/ai_digest/application/ai_digest_response_parser.dart`
- `lib/features/ai_digest/application/llm_digest_background_scheduler.dart`
- `lib/features/ai_digest/application/llm_digest_environment.dart`
- `lib/features/ai_digest/data/llm_digest_config.dart`
- `lib/features/ai_digest/data/llm_digest_run_store.dart`
- `lib/features/ai_digest/presentation/ai_digest_review_view.dart`
- `lib/app/data/relationship_repository_ai_digest.dart`
## Provider Support
`LlmService` supports:
- OpenAI
- Anthropic
- Google AI
- Ollama
- OpenAI-compatible providers
Settings owns provider setup, model listing, diagnostics, and manual test
actions.
## Privacy Model
Scheduled digest prompts use pseudonymous person tokens:
```json
{
"personToken": "person_001",
"relationshipCategory": "friend",
"interests": ["coffee"],
"capturedFacts": [...]
}
```
Do not include:
- real names
- aliases
- sender names
- source user IDs
- source thread IDs
- raw shared message text
- sensitive facts
- unreviewed facts
- source URLs from private shares
Allowed context:
- safe category labels
- sanitized fact summaries
- preference signal labels
- relative windows such as "about 3 months ago"
- `shared_message_datetime` when it is already stored as timestamp evidence
## Digest Lifecycle
```text
manual or scheduled trigger
-> LlmDigestOrchestrator
-> load config and run policy
-> build anonymized context
-> call LlmService.completeText
-> parse JSON suggestions
-> save AiSuggestionDrafts
-> notify user
-> user reviews, accepts, or dismisses
```
Suggestions never directly mutate people, reminders, or ideas without review.
## Temporal Context
Shared message timestamp handling matters. If someone wrote "I am sick and
ginger tea helped" four months ago, the correct durable outcome is probably a
ginger tea preference, not a current health check-in.
Relevant fields:
- `SharedPayload.sharedMessageDateTime`
- `SharedMessageEntry.sharedMessageDateTime`
- digest `capturedFacts[].shared_message_datetime`
- digest `capturedFacts[].sourceAge`
- digest `preferenceSignals[].lastObserved`
Prompts explicitly instruct models to avoid current check-ins from old
temporary health/status evidence.
## Web Grounding
Digest config can enable web grounding for current events. Grounded suggestions
must include source URLs and should prefer official event or venue pages.
When web grounding is disabled, the model should avoid claims about current
availability, prices, or event schedules unless the local input already contains
concrete details.
## Diagnostics
Settings exposes LLM diagnostics. Diagnostics should record metadata and lengths,
not secrets. Do not log API keys or raw private relationship context beyond what
the existing diagnostics intentionally capture.
## Tests
Useful focused tests:
```bash
flutter test test/core/llm
flutter test test/features/ai_digest
```
When changing prompt payloads, update
`test/features/ai_digest/anonymized_llm_context_builder_test.dart` so privacy
constraints are protected by tests.
+123
View File
@@ -0,0 +1,123 @@
# Architecture
## High-Level Shape
The app is mid-migration to vertical slices.
```text
lib/
main.dart
app/ app bootstrap, shell, aggregate repository/state
core/ cross-cutting config, auth, networking, LLM, theme
features/ product slices
integrations/ backend gateway contracts and transport models
```
Canonical feature structure:
```text
lib/features/<slice>/
domain/ models and pure rules
application/ orchestration, providers, side effects
presentation/ screens, widgets, dialogs, UI state
data/ slice-owned persistence or adapters, when needed
```
Many files at `lib/features/<slice>/<name>.dart` are compatibility exports.
New implementation should go into the canonical subfolders.
## Runtime Flow
1. `lib/main.dart` initializes Flutter, Sentry/Bugsink, Workmanager, local
notifications, Hive, and Riverpod.
2. `RelationshipSaverApp` wires auth gates, share listeners, notification
listeners, and global app listeners.
3. `AppShell` owns responsive navigation between Dashboard, People, Moments,
Ideas, Reminders, Signals, Sync, and Settings.
4. `LocalRepository` exposes the aggregate `LocalDataState`.
5. Feature views read state through Riverpod providers and call repository
operations.
6. Repository writes persist state, reconcile reminder schedules, and enqueue
sync envelopes where applicable.
## State And Persistence Boundary
`LocalDataState` is the primary local aggregate. It contains:
- people
- moments
- ideas
- reminders
- dashboard tasks
- source links
- shared messages
- share inbox
- person facts
- important dates
- preference signals
- AI suggestion drafts
Most product writes should pass through `LocalRepository` because it handles:
- local state update
- persistent storage write
- reminder schedule reconciliation
- sync queue enqueue
- derived side effects such as preference extraction
The sync queue is intentionally separate from the product aggregate and lives in
`SyncQueueRepository`.
## Important Providers
| Provider | Purpose |
| --- | --- |
| `localRepositoryProvider` | primary local product state and write boundary |
| `syncQueueRepositoryProvider` | pending outbound sync mutations and sync status |
| `backendGatewayProvider` | fake vs REST backend transport selection |
| `sessionControllerProvider` | fake/REST auth session lifecycle |
| `llmConfigProvider` | LLM provider, endpoint, model, and API key state |
| `llmDigestConfigProvider` | digest cadence and grounding policy |
| `reminderSchedulerProvider` | local notification scheduler abstraction |
## Design Decisions
### Local-first aggregate first
The app persists one aggregate snapshot for product state. This is simple,
offline-safe, and pragmatic for a prototype. It also means entity-level
migrations and partial writes need care.
### Feature-owned models
Even though storage is aggregate-based, models live with features. This keeps
the migration toward vertical slices open without forcing a storage rewrite.
### Fake backend by default
Fake backend mode must stay stable because the product is not yet backend
dependent. REST transport exists behind `BackendGatewayRest`.
### Inbox-first share capture
Share intake should prefer user review when identity evidence is weak. A wrong
automatic profile match is more damaging than an extra inbox step.
### Pseudonymous AI digest
Scheduled digest prompts use person tokens and safe summaries. Identifying
details stay local. Share timestamps can be included as temporal evidence, but
raw shared text and sender identity should not be sent.
### Local notifications as best effort
Reminder and digest notifications should not block local data writes. Scheduler
errors are intentionally swallowed at the repository boundary.
## Cross-Cutting Rules
- Keep `AppConfig` flags as build-time `--dart-define` friendly values.
- Add tests around parsing, local repository state changes, and prompt payload
shape when changing sensitive flows.
- Keep docs aligned with verified code, especially native share and background
behavior.
+106
View File
@@ -0,0 +1,106 @@
# Data Model
The app stores most relationship data in `LocalDataState`, persisted by
`LocalRepository`.
## Aggregate State
Source: `lib/app/state/local_data_state.dart`
| Field | Type | Meaning |
| --- | --- | --- |
| `people` | `List<PersonProfile>` | primary relationship profiles |
| `moments` | `List<RelationshipMoment>` | interaction and capture history |
| `ideas` | `List<RelationshipIdea>` | gift, activity, and follow-up ideas |
| `reminders` | `List<ReminderRule>` | local reminder rules |
| `tasks` | `List<DashboardTask>` | dashboard task surface |
| `dismissedSignalIds` | `List<String>` | dismissed generated signal IDs |
| `sourceLinks` | `List<SourceProfileLink>` | external sender/thread to profile mapping |
| `sharedMessages` | `List<SharedMessageEntry>` | imported shares attached to profiles |
| `sharedInbox` | `List<SharedInboxEntry>` | unresolved or ambiguous shares |
| `personFacts` | `List<PersonFact>` | structured relationship facts |
| `importantDates` | `List<PersonImportantDate>` | birthdays, anniversaries, events |
| `preferenceSignals` | `List<PersonPreferenceSignal>` | inferred/confirmed likes/dislikes |
| `aiSuggestionDrafts` | `List<AiSuggestionDraft>` | pending AI review items |
## Profile Model
Source: `lib/features/people/domain/person_models.dart`
`PersonProfile` is the stable profile root. Important fields:
- `id`: internal local ID
- `name`, `aliases`: local identity only
- `relationship`: user-facing relationship category
- `affinityScore`: rough closeness score
- `nextMoment`: next planned contact or relationship moment
- `tags`, `notes`, `location`: user-authored context
- `lastUpdatedAt`, `lastInteractedAt`: operational recency fields
`SourceProfileLink` maps an external source to a local profile. It is used by
share intake to avoid asking again after the user resolves a source identity.
## Share Models
Source: `lib/features/share_intake/domain/share_models.dart`
`SharedPayload` is normalized input from share sheets, action extensions, or
simulation tools.
Key timestamp fields:
- `createdAt`: when the app created the normalized payload
- `receivedAt`: when the app received/imported the share
- `sharedMessageDateTime`: best-effort original message timestamp extracted
from shared text, or null
Use `sharedMessageDateTime` as temporal evidence. Do not assume it exists. Do
not infer it from arbitrary content dates such as "birthday is May 20".
`CapturedFactDraft` is the review/edit draft before a share becomes a durable
fact or important date.
`SharedMessageEntry` is the durable record attached to a person. Its `sharedAt`
getter prefers `sharedMessageDateTime` when present.
`SharedInboxEntry` is unresolved intake. Reasons include missing identity,
ambiguous profile match, near-profile conflict, and manual selection needs.
## Facts, Dates, And Signals
`PersonFact` is durable structured context:
- `type`: note, like, dislike, importantDate, giftIdea, placeIdea,
activityIdea, misc
- `sourceKind`: manual, shareSheet, or shareInbox
- `sharedMessageId` or `inboxEntryId`: provenance
- `needsReview` and `isSensitive`: prompt and UI filtering controls
`PersonImportantDate` is a dated profile-owned record. It can come from manual
input, share review, or AI-assisted extraction.
`PersonPreferenceSignal` is inferred or confirmed preference evidence. It
tracks first/last seen timestamps, occurrence count, source apps, message IDs,
and evidence snippets.
## AI Suggestion Drafts
Source: `lib/features/ai_digest/domain/ai_digest_models.dart`
AI suggestions are saved as drafts first. Accepted drafts can become local
ideas, reminders, or tasks. Dismissed drafts stay in history so future digest
prompts can avoid repeats.
## Schema Versioning
`LocalRepository` owns `_schemaVersion`. When adding persisted fields:
1. Add nullable/defaulted fields where possible.
2. Update `toJson`, `fromJson`, and `copyWith`.
3. Increase `_schemaVersion` only when a migration has meaning beyond default
compatible parsing.
4. Add repository and serialization tests.
5. Verify legacy/missing field behavior.
The current `fromJson` patterns are intentionally tolerant for many optional
lists and fields. Keep that property.
+123
View File
@@ -0,0 +1,123 @@
# Feature Map
This file maps product areas to code ownership and maintenance notes.
## App Shell
Canonical files:
- `lib/main.dart`
- `lib/app/relationship_saver_app.dart`
- `lib/app/presentation/app_shell.dart`
- `lib/app/navigation/app_destination.dart`
Responsibilities:
- app initialization
- auth gate
- global listeners for share intake, notifications, and digest behavior
- responsive navigation
## People
Canonical files:
- `lib/features/people/domain/person_models.dart`
- `lib/features/people/presentation/people_view.dart`
- `lib/features/people/presentation/people_view_detail.dart`
- `lib/features/people/presentation/people_view_dialogs.dart`
- `lib/features/people/presentation/people_view_list.dart`
- `lib/app/data/relationship_repository_people.dart`
Use this slice for profile CRUD, duplicate merge, facts/dates display, and
profile-centered history.
## Share Intake
Canonical files:
- `lib/features/share_intake/domain/share_models.dart`
- `lib/features/share_intake/domain/share_payload_parser.dart`
- `lib/features/share_intake/domain/shared_message_datetime_extractor.dart`
- `lib/features/share_intake/presentation/incoming_share_listener.dart`
- `lib/features/share_intake/presentation/share_capture_review_sheet.dart`
- `lib/features/share_intake/presentation/share_inbox_view.dart`
- `lib/app/data/relationship_repository_share.dart`
- `ios/Share Extension/`
- `ios/Save Action/`
Use this slice for incoming shared text, URLs, chat snippets, inbox triage,
source matching, and structured capture drafts.
## AI Digest
Canonical files:
- `lib/core/llm/`
- `lib/features/ai_digest/application/`
- `lib/features/ai_digest/data/`
- `lib/features/ai_digest/domain/ai_digest_models.dart`
- `lib/features/ai_digest/presentation/ai_digest_review_view.dart`
- `lib/app/data/relationship_repository_ai_digest.dart`
Use this slice for private weekly suggestions, LLM setup, prompt payload
construction, response parsing, diagnostics, and review workflows.
## Dashboard And Signals
Canonical files:
- `lib/features/dashboard/domain/dashboard_models.dart`
- `lib/features/dashboard/presentation/dashboard_view.dart`
- `lib/features/signals/domain/signals_feed_synthesizer.dart`
- `lib/features/signals/presentation/signals_view.dart`
Dashboard is a dense overview surface. Signals are derived local prompts and
should not become another persistence source unless there is a clear product
need.
## Moments, Ideas, Reminders
Canonical files:
- `lib/features/moments/domain/moment_models.dart`
- `lib/features/ideas/domain/idea_models.dart`
- `lib/features/reminders/domain/reminder_models.dart`
- `lib/features/reminders/application/reminder_scheduler*.dart`
- `lib/app/data/relationship_repository_activity.dart`
Reminders are reconciled after local state writes. Scheduler failures should not
block local data persistence.
## Sync
Canonical files:
- `lib/features/sync/data/sync_queue_repository.dart`
- `lib/features/sync/application/sync_coordinator.dart`
- `lib/features/sync/application/sync_auto_trigger_controller.dart`
- `lib/features/sync/application/sync_background_runner.dart`
- `lib/features/sync/presentation/sync_view.dart`
- `lib/integrations/backend/`
Sync is scaffolding, diagnostics, and repair flow. Do not assume every local
entity maps to the backend contract yet.
## Settings
Canonical files:
- `lib/features/settings/presentation/settings_view.dart`
Settings is the main debug and configuration surface. It includes:
- gateway flags
- LLM configuration
- digest toggles
- manual digest run
- AI review entry
- notification permission action
- share simulation
- share diagnostics
Keep debug tools practical and explicit. Avoid hidden state changes.
+124
View File
@@ -0,0 +1,124 @@
# Implementation Playbook
This guide shows how to add features without breaking the current architecture.
## General Workflow
1. Read the slice README.
2. Identify whether the behavior is domain, application, presentation, or
repository state.
3. Add or change the feature-owned model.
4. Update `LocalDataState` only if a new persisted collection is needed.
5. Route all writes through `LocalRepository`.
6. Add focused tests.
7. Update docs when behavior changes across slices.
## Example Feature: Add A New Captured Fact Type
Suppose you want shares and manual capture to support a new fact type:
`communicationStyle`.
### Files To Change
Domain:
- `lib/features/share_intake/domain/share_models.dart`
- add enum value to `CapturedFactType`
- `lib/features/people/domain/person_models.dart`
- ensure any switch over `CapturedFactType` handles the new value
Extraction and suggestions:
- `lib/features/share_intake/domain/share_capture_draft_suggester.dart`
- `lib/core/llm/llm_service.dart`
- `lib/core/llm/captured_fact_draft_parser.dart`
Persistence:
- `lib/app/data/relationship_repository_share.dart`
- ensure `_applyFactDraftToFacts` stores the new type correctly
- `lib/app/state/local_data_state.dart`
- usually no change needed because facts already store `CapturedFactType`
UI:
- `lib/features/share_intake/presentation/share_capture_review_sheet.dart`
- `lib/features/share_intake/presentation/share_inbox_view.dart`
- `lib/features/people/presentation/people_view_detail.dart`
AI digest:
- `lib/features/ai_digest/application/anonymized_llm_context_builder.dart`
- update `_factTypeLabel`
Tests:
- `test/features/share_intake/share_capture_draft_suggester_test.dart`
- `test/core/llm/captured_fact_draft_parser_test.dart`
- `test/features/local/local_repository_test.dart`
- `test/features/ai_digest/anonymized_llm_context_builder_test.dart`
### Rules
- Add enum values at the end unless there is a strong reason to reorder.
- Keep `fromJson` fallback behavior.
- Add tests for unknown or missing values when changing parsing behavior.
- Do not make LLM output the only way to create the new type. Provide a local
fallback or review UI path.
## Example Feature: Add A New Share Source Parser
Suppose you want better Telegram parsing.
Files:
- Add `lib/features/share_intake/domain/telegram_share_parser.dart`.
- Update `SharePayloadParser._normalizeSourceApp`.
- Update `SharePayloadParser._parseSourceMetadata`.
- Add tests in `test/features/share_intake/`.
Parser expectations:
- Return message text without source prefix noise.
- Extract display name only when the pattern is strong.
- Extract stable source IDs only when they are provided by the source.
- Leave unknown timestamps null.
- Never throw on malformed input.
Repository expectations:
- Exact identity match can auto-link only when unambiguous.
- Near matches should route to Share Inbox.
- Source profile links should be stable across follow-up shares.
## Example Feature: Sync A New Entity
Suppose AI suggestion drafts need REST sync.
Files:
- `docs/api/openapi.yaml`
- `lib/integrations/backend/models/backend_models.dart`
- generated backend model files
- `lib/integrations/backend/backend_gateway_rest.dart`
- `lib/integrations/backend/backend_gateway_fake.dart`
- `lib/integrations/backend/sync_envelope_validator.dart`
- `lib/app/data/relationship_repository_ai_digest.dart`
- tests under `test/integrations/backend` and `test/features/sync`
Design choices to document:
- entity type string in `ChangeEnvelope`
- create/update/delete semantics
- conflict behavior
- whether rejected changes are user-repairable
- privacy implications
## Review Checklist Before Shipping
- `flutter analyze`
- focused tests for touched slices
- full `flutter test` when practical
- manual Settings smoke test for config/debug actions
- manual share simulation test after share changes
- physical-device test after native share, notification, or background changes
+92
View File
@@ -0,0 +1,92 @@
# Onboarding
This app is a local-first Flutter prototype for capturing relationship context:
people, moments, reminders, ideas, shared messages, preference signals, and
private AI-assisted suggestions.
The most important product constraint is trust. The app should preserve
relationship details locally by default, avoid accidental profile matching, and
send only pseudonymous, reviewed context to LLM flows.
## First Hour
1. Read this file, then [Architecture](ARCHITECTURE.md), [Data Model](DATA_MODEL.md),
and [Feature Map](FEATURE_MAP.md).
2. Run `flutter pub get`.
3. Run `flutter analyze`.
4. Run focused tests before full tests:
- `flutter test test/features/share_intake`
- `flutter test test/features/ai_digest`
- `flutter test test/features/local`
5. Run the app with the fake backend:
```bash
flutter run --dart-define=USE_FAKE_BACKEND=true
```
## Project Posture
The app is not a fully server-backed production product yet. It is a
prototype-friendly, local-first app with REST/OpenAPI scaffolding for later
backend work. Fake backend mode is the default development path and should remain
safe offline.
Current product strengths:
- people profiles with aliases, notes, tags, facts, dates, and preference
signals
- manual capture for moments, ideas, reminders, and dashboard tasks
- share intake with inbox-first review for ambiguous content
- local notifications for reminders
- private weekly AI digest with pseudonymous prompt payloads
- fake backend auth/sync plus REST gateway boundary
Current constraints:
- data is mostly stored as one local aggregate snapshot
- many backend mappings are incomplete for newer local entities
- iOS share support has native targets, but real device behavior still needs
continued verification
- tests are good for parsers/repositories/widgets, but native share and
background scheduling need manual device testing
## Daily Development Commands
```bash
flutter pub get
flutter analyze
flutter test
dart run build_runner build --delete-conflicting-outputs
```
Useful run modes:
```bash
flutter run --dart-define=USE_FAKE_BACKEND=true
flutter run --dart-define=USE_FAKE_BACKEND=false --dart-define=BACKEND_BASE_URL=https://your-api
flutter run --dart-define=ENABLE_BACKGROUND_SYNC=false
flutter run --dart-define=ENABLE_LOCAL_NOTIFICATIONS=true
```
## Where To Start For Common Work
| Task | Start Here |
| --- | --- |
| Add or change persisted relationship data | `lib/app/state/local_data_state.dart`, model file in `lib/features/*/domain/`, repository part in `lib/app/data/` |
| Change profile UI | `lib/features/people/presentation/` |
| Change share capture | `lib/features/share_intake/`, `lib/app/data/relationship_repository_share.dart`, native files under `ios/` or Android manifest |
| Change AI suggestions | `lib/features/ai_digest/`, `lib/core/llm/` |
| Change reminder behavior | `lib/features/reminders/`, `lib/app/data/relationship_repository.dart` schedule reconciliation |
| Change backend sync | `lib/features/sync/`, `lib/integrations/backend/`, `docs/api/openapi.yaml` |
| Add settings/debug tools | `lib/features/settings/presentation/settings_view.dart` |
## Non-Negotiable Maintainer Rules
- Prefer canonical slice files under `presentation/`, `application/`, `domain/`,
and `data/`.
- Do not expand compatibility exports unless preserving old imports is the task.
- Keep local-first behavior working in fake backend mode.
- Treat ambiguous shared identities as inbox items, not auto-linked profiles.
- Never include names, aliases, sender names, source URLs, or raw shared text in
scheduled digest prompts.
- Update the nearest doc when changing architecture-level behavior.
+39
View File
@@ -0,0 +1,39 @@
# Documentation Index
Start here when taking over the app:
- [Onboarding](ONBOARDING.md): first-day setup, mental model, and where to look.
- [Architecture](ARCHITECTURE.md): module boundaries, state flow, and design
decisions.
- [Data Model](DATA_MODEL.md): persisted local state and important domain
models.
- [Feature Map](FEATURE_MAP.md): product slices, canonical files, and common
extension points.
- [Share Intake](SHARE_INTAKE.md): iOS/Android share entry, parsing, inbox, and
timestamp handling.
- [AI Digest](AI_DIGEST.md): LLM providers, privacy model, prompts, digest
lifecycle, and review workflow.
- [Storage And Migrations](STORAGE_AND_MIGRATIONS.md): Hive, legacy shared
preferences, schema versioning, and migration rules.
- [Sync And Backend](SYNC_AND_BACKEND.md): fake/REST gateway boundary, sync
queue, OpenAPI contract, and repair flow.
- [Implementation Playbook](IMPLEMENTATION_PLAYBOOK.md): example feature
implementation and files to update.
- [Testing And Debugging](TESTING_AND_DEBUGGING.md): commands, fixtures,
diagnostics, and known test risks.
- [Roadmap And Risks](ROADMAP_AND_RISKS.md): current gaps, priorities, and
traps for maintainers.
Historical planning docs are still useful for context, but treat the files above
as the current takeover documentation.
Existing historical docs:
- [Progress Log](progress.md)
- [Open Tasks](open-tasks.md)
- [Client Audit](client-audit-relationship-app.md)
- [Client Implementation Plan](client-implementation-plan.md)
- [Client Iteration Summary](client-iteration-summary.md)
- [Backend REST/OpenAPI ADR](ADR/0002-backend-protocol-rest-openapi.md)
- [Backendless Local LLM ADR](ADR/0003-backendless-local-llm-pipeline.md)
- [OpenAPI Contract](api/openapi.yaml)
+92
View File
@@ -0,0 +1,92 @@
# Roadmap And Risks
## Near-Term Priorities
1. Keep share intake reliable and conservative.
2. Improve structured fact/date review UX.
3. Stabilize private AI digest behavior with strong privacy tests.
4. Verify iOS share/action extensions on physical devices.
5. Decide which new local entities must sync to a backend.
6. Reduce large UI files after behavior stabilizes.
## Product Risks
### Wrong profile matching
Risk: a shared message gets attached to the wrong person.
Mitigation:
- prefer Share Inbox for ambiguous evidence
- keep source links stable after user confirmation
- test duplicate and near-match behavior
### Over-eager AI check-ins
Risk: old temporary status messages produce awkward current reminders.
Mitigation:
- preserve `sharedMessageDateTime`
- include source age in digest context
- instruct prompts to treat old health/status as historical evidence
- prefer durable preferences when evidence is old
### Privacy leakage
Risk: raw names, sender details, or private text reaches scheduled digest
prompts.
Mitigation:
- keep prompt builder tests strict
- sanitize identity terms
- exclude sensitive and unreviewed facts
- keep AI results in review drafts first
### Local storage migration mistakes
Risk: changing aggregate JSON breaks existing installs.
Mitigation:
- default nullable fields
- tolerant `fromJson`
- focused migration tests
- avoid destructive recovery paths
### Native platform drift
Risk: iOS/Android share, notifications, or background tasks compile but fail on
real devices.
Mitigation:
- physical device test for native features
- keep setup docs current
- verify Podfile and Xcode project after plugin upgrades
## Technical Debt
- Aggregate storage is simple but will eventually limit multi-device sync and
conflict handling.
- `PeopleView`, `DashboardView`, and Settings are large and should be split when
behavior changes require deeper edits.
- Compatibility exports should remain stable but should not receive new logic.
- Backend protocol does not yet cover every local-first entity.
- Some full-suite widget tests are layout-sensitive.
## Decision Backlog
- Which entities are first-class backend resources?
- Should AI suggestion drafts sync across devices or remain device-local?
- Should source-message timestamp extraction support locale settings explicitly?
- Should share inbox support batch triage?
- Should preference signals be promotable to profile tags?
- Should the graph explorer become its own feature slice?
## Maintenance Rule
When a roadmap item is completed or invalidated, update this file and the
nearest feature doc. The old progress log is useful history, but this file
should describe the current maintainer view.
+87 -1
View File
@@ -1,5 +1,8 @@
# Setup
For a full handoff path, start with [Onboarding](ONBOARDING.md), then use this
file for concrete runtime flags and platform setup notes.
## Backend Base URL
Build-time (recommended):
@@ -14,6 +17,33 @@ Runtime override (for future settings screens):
AppConfig.overrideBackendBaseUrl('https://staging.example.com');
```
## Bugsink Error Tracking
Release builds initialize `sentry_flutter` with the project Bugsink DSN by
default. Bugsink is Sentry API compatible, so the app uses the normal Sentry
Flutter SDK.
The default DSN is:
```text
https://97fb0a56bc35440fba0ba139dc3b1ccc@bugs.zuzo.ch/2
```
Override metadata at build time when needed:
```bash
flutter build apk --release \
--dart-define=SENTRY_ENVIRONMENT=production \
--dart-define=SENTRY_RELEASE=relationship_saver@1.0.0+1
```
To point a build at a different Sentry-compatible project:
```bash
flutter build apk --release \
--dart-define=SENTRY_DSN=https://public-key@example.com/1
```
## Run With Fake Gateway
Use fake gateway for local/offline development:
@@ -25,7 +55,8 @@ flutter run --dart-define=USE_FAKE_BACKEND=true
## Background Sync
Background sync (startup + resume + periodic ticks while authenticated) is
enabled by default.
disabled by default. The current product direction is backendless/local-first,
so backend sync should be enabled only for REST transport development.
In REST mode, auto-triggers are reachability-gated to avoid unnecessary sync
attempts while offline.
@@ -75,6 +106,58 @@ Behavior notes:
permission prompts
- tapping a reminder notification opens reminder management UI in-app
## Phone-Only Private AI Digest
The app can run without a production backend by keeping `USE_FAKE_BACKEND=true`
and using the LLM integration for local profile extraction and private digest
generation.
Selected product flow:
- share messages/text from WhatsApp, iMessage, Notes, Safari, or similar apps
into the app
- auto-match only when identity evidence is strong
- keep ambiguous or low-information shares in `Share Inbox`
- let the user gradually resolve inbox items over days, weeks, and months
- run local/profile-building extraction opportunistically and on scheduled
nightly windows when the OS allows it
- run grounded weekly recommendations on request or on the configured digest
schedule
Backend sync is not part of this path. The fake backend is intentionally inert
for sync pulls and must not seed people or other relationship records.
Digest behavior:
- relationship data remains local-first in the Hive/shared-preferences store
- scheduled digest payloads use pseudonymous tokens such as `person_001`
- names, aliases, sender names, source URLs, raw shared text, and sensitive
notes are not sent in the scheduled digest prompt
- repeated LLM work should be avoided by fingerprinting unresolved/new share
batches and skipping extraction when the same batch has already completed
- weekly recommendation prompts should include previously suggested/dismissed
items so the model avoids repeats
- LLM results are saved as pending AI review drafts first
- accepting a draft creates a local idea, task, or reminder
- dismissing a draft leaves existing local data unchanged
Scheduling:
- default cadence is weekly
- default run window is Sunday 21:00
- default policy is Wi-Fi + charging
- iOS background execution is best-effort because the OS controls exact timing
- Settings includes `Run Private Digest Now` for physical-phone debug builds
iOS setup now includes:
- `UIBackgroundModes`: `fetch`, `processing`
- `BGTaskSchedulerPermittedIdentifiers`: `com.relationshipsaver.llm.digest`
- Workmanager registration in `AppDelegate.swift`
Local notifications are used for the digest-ready alert. No APNs/FCM remote
push payload is required.
## WhatsApp Share Intake (MVP)
Inbound WhatsApp share-intake is now wired behind a listener in the authenticated
@@ -95,6 +178,9 @@ flutter run --dart-define=ENABLE_WHATSAPP_SHARE_INTAKE=false
Behavior notes:
- iOS/Android: integrates with share-intent plugin (`receive_sharing_intent`)
- iOS also ships a `Save to Relationship Saver` Action extension for the lower
share-sheet action row; use it when that row is easier to reach than the app
share row
- shared text is parsed and ingested into:
- person profile (auto-create when identity is confident)
- source->profile link map for follow-up matching
+121
View File
@@ -0,0 +1,121 @@
# Share Intake
Share intake is the app's highest-friction capture path. It should be reliable,
reviewable, and conservative about identity matching.
## Entry Points
Flutter listener:
- `lib/features/share_intake/presentation/incoming_share_listener.dart`
Native iOS:
- `ios/Share Extension/ShareViewController.swift`
- `ios/Save Action/SaveActionViewController.swift`
- `ios/Share Extension/Info.plist`
- `ios/Save Action/Info.plist`
- `ios/Runner.xcodeproj/project.pbxproj`
Android:
- `android/app/src/main/AndroidManifest.xml`
- `receive_sharing_intent` plugin integration
Developer simulation:
- Settings -> `Simulate Share Capture`
## Pipeline
```text
native share / simulation
-> IncomingShareListener
-> SharePayloadParser
-> startShareCaptureReview
-> capture to existing profile, create profile, or save to inbox
-> LocalRepositoryShareOperations
-> LocalDataState + sync queue + optional facts/dates/signals
```
## Payload Normalization
`SharePayloadParser` normalizes:
- source app labels
- text vs URL vs text-with-URL payloads
- WhatsApp sender prefixes
- Signal sender prefixes
- source-message timestamp evidence
`SharedPayload` keeps both app-side timestamps and source-message timestamps:
- `createdAt`: normalized payload creation time
- `receivedAt`: app receive/import time
- `sharedMessageDateTime`: best-effort original message time, nullable
## Source Message Timestamp Extraction
Source: `lib/features/share_intake/domain/shared_message_datetime_extractor.dart`
The extractor tries multiple metadata-like patterns:
- bracketed chat prefixes such as `[19/05/2026, 14:30] Sender: text`
- exported chat prefixes such as `5/19/26, 2:30 PM - Sender: text`
- labelled lines such as `Received at: 19 May 2026 at 14:30`
- ISO-like timestamps
- `today at HH:mm` and `yesterday at HH:mm`
- numeric and month-name date variants
It returns `null` instead of throwing. It intentionally avoids arbitrary content
dates, for example `birthday is May 20`, because those are not message receive
times.
## Identity Matching
Matching order:
1. Existing `SourceProfileLink` by source fingerprint/user/thread metadata.
2. Exact normalized display name when exactly one profile matches.
3. Near-name conflict detection.
4. Inbox when missing, ambiguous, or risky.
Do not auto-link when evidence is weak. The inbox is the safety valve.
## Inbox Resolution
Share Inbox supports:
- resolving to an existing person
- creating a person
- editing structured draft type/text/date
- reviewing conflict candidates
- saving provenance for facts and source links
Relevant files:
- `lib/features/share_intake/presentation/share_inbox_view.dart`
- `lib/features/share_intake/presentation/share_capture_review_sheet.dart`
- `lib/app/data/relationship_repository_share.dart`
## Native iOS Notes
The repository currently contains Share Extension and Save Action target entries
in the Xcode project. The iOS Podfile still only has the `Runner` target, so
verify extension build behavior after plugin updates or Pod changes.
Physical iPhone testing is required for real chat apps. Simulators are useful
for Notes/Safari-style text and URL sharing, but not enough for WhatsApp,
iMessage, or app-specific metadata behavior.
## Tests
Useful focused tests:
```bash
flutter test test/features/share_intake
flutter test test/features/local/local_repository_test.dart
```
Add parser tests for every new source format. Add repository tests when matching,
inbox, source links, facts, or signals change.
+81
View File
@@ -0,0 +1,81 @@
# Storage And Migrations
## Local Product Store
The product store is abstracted by `LocalDataStore`.
Implementations:
- `HiveLocalDataStore`
- `SharedPrefsLocalDataStore`
- `InMemoryLocalDataStore`
Provider:
- `lib/app/data/storage/local_data_store_provider.dart`
Default:
- `USE_HIVE_LOCAL_DB=true`
## Sync Store
The sync queue has a separate store abstraction:
- `lib/features/sync/data/storage/sync_state_store.dart`
- `HiveSyncStateStore`
- `SharedPrefsSyncStateStore`
- `InMemorySyncStateStore`
This separation is intentional. Product state and sync queue state have
different recovery and migration needs.
## Migration Behavior
On startup, `LocalRepository`:
1. Reads the configured product store.
2. If Hive is enabled and empty, attempts to import legacy shared preferences.
3. Writes the migrated data into Hive.
4. Clears the legacy store.
5. Parses `LocalDataState`.
6. Reconciles reminder schedules.
If stored JSON is invalid, the app falls back to empty seeded state.
`SyncQueueRepository` follows the same Hive-first import pattern for sync state.
## Adding Persisted Fields
Checklist:
1. Add the field to the feature-owned model.
2. Update constructor, field declaration, `copyWith`, `toJson`, and `fromJson`.
3. Use default values or nullable parsing for backward compatibility.
4. Add the field to `LocalDataState` if it is a new aggregate collection.
5. Update repository operations that should create, update, merge, or delete it.
6. Update merge behavior if the field is person-owned.
7. Update sync payloads if the backend should receive it.
8. Add tests for old JSON and new JSON.
Increase `_schemaVersion` in `LocalRepository` only when the migration needs
explicit logic or when a stored shape changes in a way that cannot be handled by
default parsing.
## Data Loss Traps
- Do not remove unknown lists from `LocalDataState.fromJson` without a migration.
- Do not clear Hive/shared preferences to "fix" parse issues except in tests.
- When deleting a person, check all person-owned collections and source links.
- When merging people, move facts, dates, signals, messages, inbox candidates,
reminders, ideas, moments, and source links to the target profile.
- Reminder scheduling is a side effect of repository writes; failures are
swallowed by design.
## Useful Tests
```bash
flutter test test/features/local
flutter test test/features/local/storage
flutter test test/features/sync/storage
```
+87
View File
@@ -0,0 +1,87 @@
# Sync And Backend
The app has a backend boundary but remains local-first by default.
## Runtime Modes
Fake backend:
```bash
flutter run --dart-define=USE_FAKE_BACKEND=true
```
REST backend:
```bash
flutter run \
--dart-define=USE_FAKE_BACKEND=false \
--dart-define=BACKEND_BASE_URL=https://your-api
```
`BackendGatewayFake` is the normal development path. It should stay
offline-safe and must not seed relationship records into local state.
## Backend Boundary
Canonical files:
- `lib/integrations/backend/backend_gateway.dart`
- `lib/integrations/backend/backend_gateway_fake.dart`
- `lib/integrations/backend/backend_gateway_rest.dart`
- `lib/integrations/backend/backend_gateway_provider.dart`
- `lib/integrations/backend/models/backend_models.dart`
- `docs/api/openapi.yaml`
The OpenAPI contract is the source for REST protocol intent. Generated Freezed
files are committed and should be regenerated when backend models change.
## Sync Queue
Canonical files:
- `lib/features/sync/data/sync_queue_repository.dart`
- `lib/features/sync/application/sync_coordinator.dart`
- `lib/features/sync/application/sync_auto_trigger_controller.dart`
- `lib/features/sync/application/sync_background_runner.dart`
- `lib/features/sync/presentation/sync_view.dart`
Local writes enqueue `ChangeEnvelope` objects. Push accepts or rejects them.
Rejected changes can be inspected and repaired from Sync view.
## Auto Sync
Background sync is disabled by default in the current local-first posture:
```text
ENABLE_BACKGROUND_SYNC=false
```
When enabled, triggers include startup, resume, periodic intervals, and
offline-to-online transitions. REST mode is reachability-gated.
## Known Mapping Gap
Not every local entity maps to the backend protocol yet. Newer local-first
entities such as facts, important dates, preference signals, shared inbox, and
AI drafts may need explicit backend protocol decisions before real multi-device
sync.
Do not assume adding a local model automatically syncs it.
## Backend Change Checklist
1. Update `docs/api/openapi.yaml`.
2. Update backend DTOs in `lib/integrations/backend/models/`.
3. Regenerate generated models when needed.
4. Update REST gateway mapping.
5. Update fake gateway behavior.
6. Update sync envelope validation.
7. Update repository enqueue payloads.
8. Add serialization and sync coordinator tests.
Useful tests:
```bash
flutter test test/integrations/backend
flutter test test/features/sync
```
+117
View File
@@ -0,0 +1,117 @@
# Testing And Debugging
## Baseline Commands
```bash
flutter analyze
flutter test
dart run build_runner build --delete-conflicting-outputs
```
Focused suites:
```bash
flutter test test/features/share_intake
flutter test test/features/ai_digest
flutter test test/features/local
flutter test test/features/sync
flutter test test/core/llm
flutter test test/integrations/backend
```
## Manual Smoke Tests
Fake backend:
1. Launch with `USE_FAKE_BACKEND=true`.
2. Sign in with fake credentials.
3. Add a person.
4. Add a moment.
5. Add an idea.
6. Add a reminder.
7. Open Settings and verify flags.
Share intake:
1. Settings -> `Simulate Share Capture`.
2. Paste a sender-prefixed text share.
3. Confirm review sheet behavior.
4. Verify profile attachment or Share Inbox routing.
5. Open profile and check facts/messages/signals.
AI digest:
1. Configure provider in Settings.
2. Run LLM Debug if needed.
3. Enable weekly digest.
4. Run Private Digest Now.
5. Open AI Review.
6. Accept and dismiss drafts.
Notifications:
1. Request notification access from Settings.
2. Create or update reminders.
3. Verify scheduler tests still pass.
4. Test real delivery on device where possible.
## Debug Surfaces
Settings includes:
- LLM Debug
- LLM diagnostics export
- Run Private Digest Now
- Open AI Review
- Request Notification Access
- Simulate Share Capture
- Open Share Inbox
- Share Diagnostics
Sync view includes:
- pending changes
- rejected changes
- repair actions
- retry/requeue tools
## Common Failures
### Share intent delivered nothing
Check:
- `ENABLE_WHATSAPP_SHARE_INTAKE`
- iOS extension target build
- App Group ID matches Runner and extension targets
- `ReceiveSharingIntent.instance.getInitialMedia()`
- Share Diagnostics in Settings
### LLM digest creates bad suggestions
Check:
- `AnonymizedLlmContextBuilder` payload
- sensitive/unreviewed facts filtering
- prior suggestions included
- source age and `shared_message_datetime`
- parser response tests
### Background digest does not run
This can be normal. iOS background execution is best effort. Verify manual run
first, then check Workmanager setup, background modes, charging/network policy,
and run-state backoff.
### Full widget tests fail due layout hit targets
Some app-flow tests are sensitive to viewport/layout. Prefer focused tests for
feature work, then investigate full-suite failures separately with screenshots
or widget tree dumps.
## Test Data Rules
- Avoid real names, messages, phone numbers, or URLs in fixtures.
- Use synthetic names and pseudonymous tokens.
- Add regression tests for parser edge cases.
- Add privacy assertions when prompt payloads change.
+139
View File
@@ -0,0 +1,139 @@
# Client Audit: Relationship Assistant App
Date: 2026-04-01
## Current implementation summary
The client is a Flutter app using Riverpod for state management and a single
`MaterialApp` rooted at `lib/main.dart`. Authenticated users land in a custom
shell with index-based navigation across dashboard, people, moments, signals,
ideas, reminders, sync, and settings.
The app is already local-first in practice. Product state lives in
`LocalRepository` and is persisted through a `LocalDataStore` abstraction with
Hive as the default backend and shared preferences as a fallback/migration
path. There is also background sync scaffolding, notification scheduling, and a
fake backend mode for offline development.
Share intake exists, but the implementation is narrow. Flutter-side logic
supports a WhatsApp-oriented share parser, source-link resolution, and a manual
share inbox. Native platform wiring is incomplete for production-grade cross-app
sharing, especially on iOS and likely also on Android for text share intents.
## A. Current implementation inventory
| Feature Area | Current State |
| --- | --- |
| Client technology | Flutter |
| State management | Riverpod (`Notifier`, `AsyncNotifier`, provider overrides in tests) |
| Navigation | Single `MaterialApp`, custom shell, route pushes for secondary flows |
| Local persistence | Hive by default, shared preferences fallback/migration |
| Auth | Local/fake-friendly sign-in flow with token-store abstractions |
| People list/detail | Implemented, responsive, searchable, editable |
| Person creation/editing | Implemented with validation and duplicate-name warning |
| Notes/profile context | Implemented as a single freeform notes field plus tags |
| Moments/captures | Implemented |
| Ideas | Implemented |
| Reminders | Implemented with local notification scaffold |
| Shared message history | Implemented per profile |
| Share intake | Implemented in Flutter, but WhatsApp-specific and not fully generalized |
| Share inbox | Implemented for unresolved profile matching |
| Source linking | Implemented for repeated source-to-profile matching |
| Preference inference | Implemented locally from shared chat text |
| Search/filtering | Implemented for people; limited elsewhere |
| Theming/design system | Implemented via `AppTheme`, custom frosted cards, responsive layouts |
| Tests | Good targeted widget/unit coverage, no true end-to-end native share tests |
| Backend sync readiness | Good scaffolding, still fake/offline-friendly |
| Privacy/trust UX | Limited; local-first behavior exists more in code than in user-facing copy |
| Cross-platform share viability | Partial; Flutter layer exists, native integration incomplete |
## B. Grading
| Feature Area | Status | Grade | Notes | Risk / Missing Pieces | Recommended Next Step |
| --- | --- | --- | --- | --- | --- |
| App architecture | Present | B | Clear enough separation between UI, local repository, sync, and integrations | Some large UI files, especially `PeopleView` | Add smaller share/fact modules instead of broad refactors |
| Local-first persistence | Present | A- | Hive-backed state with migration path and test coverage | Still JSON-in-Hive rather than richer queryable store | Keep current approach for this iteration |
| People management | Present | B | Good responsive UI, editing, duplicate warning, merge flow | Person model is too thin for relationship intelligence | Extend model with aliases, facts, dates, timestamps |
| Moments / history | Present | B | Useful local activity log | History is not yet strongly typed for preferences/dates/fact provenance | Add structured facts on top rather than replacing moments |
| Ideas / reminders | Present | B | Solid founder-mode utility already | Not tightly connected to share capture | Reuse future share classification to feed these areas later |
| Share intake pipeline | Partial | C | Flutter-side ingest and inbox exist | WhatsApp-specific, no generic text/URL pipeline, native wiring incomplete | Generalize payload normalization and improve clarification UX |
| Person resolution | Partial | B- | Exact/near-match logic and source links already exist | Resolution is tied to sender matching and not broader share assignment | Add explicit assign/create/save-to-inbox review flow |
| Inbox / triage | Partial | C+ | Resolves ambiguous source matching | Cannot yet classify into structured facts/dates/preferences | Add fact classification and later reassignment/edit support |
| Search / discoverability | Partial | C+ | People search/filter exists | No real inbox/fact/date search | Keep scope limited; prepare data for later search expansion |
| Privacy / trust UX | Weak | D | Local-first architecture exists | App does not clearly explain on-device storage or future AI sharing controls | Add clear local-first settings copy and sensitive-data affordances |
| Cross-platform share readiness | Weak | D | Plugin added, listener implemented | Android manifest missing share intent filters; iOS share extension not configured | Fix Android now, document iOS as deferred native work |
| Test posture | Present | B+ | Good unit/widget coverage for local repo and share inbox | Missing tests for generalized share parsing and structured triage | Add targeted parser/repository/widget tests |
## C. Product readiness assessment
### Solo founder daily use
Current usefulness is moderate. The app already supports keeping people,
captures, reminders, and ideas locally, which is enough for founder testing.
However, the most important real-world capture path, sharing snippets from other
apps, is not yet strong enough to rely on daily.
### Friction of data entry
Manual entry is acceptable. Cross-app capture still has too much friction
because it is optimized around one parser path instead of a general intake
flow. Ambiguous content can be saved, but not yet shaped into structured,
reviewable facts.
### Trust / privacy feel
The implementation is local-first, but the UX does not communicate that clearly
enough. For a relationship app, trust is part of the product. The app should
say what stays on device, what is not yet shared, and how users can correct or
delete imported facts.
### Extensibility for future LLM support
Reasonably good. The repository pattern, fake backend mode, and local inferred
preference signals create a workable foundation. The main gap is data shape:
profile context is still too flat, and shared content is not normalized into a
recommendation-ready structure.
### Cross-platform viability
Good at the Flutter layer, incomplete at the native share-entry layer. Android
needs explicit share intent wiring. iOS still lacks a real share extension.
### Likely adoption blocker
The biggest blocker is not “AI quality”; it is capture friction. If sharing a
snippet from WhatsApp, Safari, Notes, or Messages into the app is unreliable or
too manual, the product will not accumulate enough high-value context to become
habit-forming.
## D. Top implementation priorities
1. Generalize share capture into a platform-aware text/URL pipeline with a
clear internal payload model and Android native entry wiring.
2. Add an inbox-first clarification flow that lets the user assign to an
existing person, create a new person quickly, or save unresolved data for
later.
3. Extend the local person knowledge model with aliases, structured facts,
important dates, timestamps, and source traceability.
4. Upgrade the inbox from “resolve person only” to “triage into note / like /
dislike / date / idea-ish fact”.
5. Improve privacy/trust wording and make correction paths more obvious.
## Recommended scope for this iteration
Implement a disciplined client-only slice:
- generic shared text / URL normalization
- Android text share intent support
- share clarification sheet for assign/create/inbox
- richer local fact/date models with provenance
- inbox triage into structured facts or important dates
- person detail UI for reviewing captured facts/dates
- targeted tests
Explicitly defer:
- native iOS share extension target work
- real backend sync of new fact/date entities
- real LLM or recommendation calls
- large navigation or persistence rewrites
+173
View File
@@ -0,0 +1,173 @@
# Client Implementation Plan
Date: 2026-04-01
## Chosen scope for this iteration
This iteration focuses on the smallest slice that materially improves founder
testing:
1. Generalize intake from WhatsApp-only text import to a broader shared payload
pipeline that supports:
- plain text
- URL
- text + URL
2. Add an explicit clarification flow for ambiguous shares:
- assign to existing person
- create person quickly
- save to inbox for later
3. Improve the local person knowledge model with:
- aliases
- structured captured facts
- important dates
- timestamps
- source/provenance metadata
4. Upgrade inbox triage so unresolved shares can be converted into:
- note
- like
- dislike
- important date
- gift idea
- place idea
- activity idea
- misc fact
5. Add trust-facing settings copy and targeted tests.
## Why this scope
- It directly improves the most important product workflow: fast capture from
outside the app.
- It stays client-side and local-first.
- It does not require a storage rewrite or backend coupling.
- It produces data structures that future LLM/recommendation work can consume.
- It keeps risk contained by extending the existing repository and views rather
than replacing them.
## Affected files / modules
### Expected to change
- `lib/main.dart`
- `lib/features/local/local_models.dart`
- `lib/features/local/local_repository.dart`
- `lib/features/people/people_view.dart`
- `lib/features/settings/settings_view.dart`
- `lib/features/share_intake/share_inbox_view.dart`
- `lib/features/share_intake/whatsapp_share_listener.dart` or replacement
- `android/app/src/main/AndroidManifest.xml`
- `test/features/local/local_repository_test.dart`
- `test/features/share_intake/share_inbox_view_test.dart`
### Expected to add
- generic share payload parser / models
- share review sheet / dialog component
- parser unit tests
- iteration summary doc
## Architecture decisions
### 1. Keep `LocalRepository` as the orchestration layer
Reason:
- It already owns local persistence, entity updates, and ingestion logic.
- Extending it is safer than introducing a second persistence coordinator.
Tradeoff:
- The file remains large.
Mitigation:
- Add focused helpers/models for share parsing and captured-fact typing rather
than pushing more UI logic into the repository.
### 2. Add structured fact/date entities instead of overloading `notes`
Reason:
- Future recommendation features need provenance, timestamps, and type-safe
context.
- Users need auditability and correction, not just a bigger notes blob.
Tradeoff:
- More model surface area now.
Mitigation:
- Keep entity types intentionally small and local-only for this iteration.
### 3. Prefer inbox-first safety over aggressive auto-assignment
Reason:
- Wrong person attachment is worse than extra triage work in this domain.
Decision:
- Only auto-assign when confidence is genuinely high.
- Otherwise open clarification UI or save to inbox.
### 4. Fix Android share entry now, defer iOS native extension
Reason:
- Android manifest support is low-cost and unblocks real testing.
- Proper iOS share extension work is native-project-heavy and should be treated
as a separate scoped task.
## UX flows
### Flow A: direct share received with high-confidence target
1. Normalize incoming share into `SharedPayload`.
2. If a stable source link or clear unique alias/name match exists, allow direct
save to the person.
3. Persist the payload history plus a structured fact defaulting to `note`
unless a better classification is explicitly chosen.
4. Show confirmation snackbar and navigate to the relevant destination on cold
start.
### Flow B: share received without safe target
1. Normalize into `SharedPayload`.
2. Open a lightweight review sheet when possible.
3. Offer:
- recent people / candidate people
- quick create person
- save to inbox
4. If the user saves to inbox, persist payload + draft classification and leave
the item unresolved.
### Flow C: inbox triage later
1. Open `Share Inbox`.
2. Review the preview text/URL and suggested candidates.
3. Choose person or create one.
4. Choose classification and edit the extracted content.
5. Save as structured fact/date.
## Risks
### Risk: model expansion causes migration issues
Fallback:
- Keep new JSON fields backward-compatible with defaults in `fromJson`.
### Risk: generic share parsing creates false confidence
Fallback:
- Default to inbox / manual clarification for unclear payloads.
### Risk: too much UI churn in `PeopleView`
Fallback:
- Add new review sections for facts/dates without reworking the whole people
editor architecture.
### Risk: native plugin behavior differs across platforms
Fallback:
- Keep settings-based share simulation path for deterministic local testing.
## Intentionally deferred
- iOS share extension target creation and App Group setup
- image/file share support
- sync mapping for new fact/date entities
- AI-driven classification/recommendations
- full-text search across inbox/facts/dates
- notification engine hooks for new entities
+146
View File
@@ -0,0 +1,146 @@
# Client Iteration Summary
Date: 2026-04-01
## What was implemented
This iteration focused on client-side share capture, local-first structure, and
safer person assignment.
Implemented:
- generic shared payload normalization for:
- plain text
- URL
- text + URL
- a review-first share capture sheet for:
- assign to existing person
- create person quickly
- save to inbox
- richer local person model support:
- aliases / nicknames
- last updated / last interacted timestamps
- richer local knowledge entities:
- structured captured facts
- important dates
- source/provenance metadata
- sensitive flag scaffolding
- inbox triage improvements:
- draft mapping to note / like / dislike / date / gift / place / activity / misc
- create person directly from inbox
- resolve to existing person with fact mapping
- people profile UI improvements:
- aliases visible in profile context
- captured facts section
- important dates section
- edit/delete support for facts and dates
- Android native text share entry:
- `ACTION_SEND` text intent filter added to `AndroidManifest.xml`
- trust/privacy wording:
- clearer local-first copy in settings
- tests:
- generic share payload parser tests
- repository tests for inbox-to-fact and date persistence
- updated share inbox widget test flow
## Architecture decisions
### Kept the existing repository-centered local architecture
`LocalRepository` remains the orchestrator for local persistence and ingest.
This avoided a risky refactor and let the new share pipeline build on existing
person/source-link/moment infrastructure.
### Added structured fact/date entities instead of overloading notes
This creates a cleaner bridge to future recommendation, summarization, and sync
work. Shared captures can now produce auditable, typed local knowledge instead
of only raw note blobs.
### Used a review-first share flow for safety
Rather than silently guessing in generic share cases, the app now asks for a
target person or lets the user save to inbox. This is the safer default for a
relationship product.
## Known limitations
- iOS still does not have a real share extension target configured. The Flutter
share logic exists, but native iOS share-sheet presence remains deferred.
- Android share support added here is text-focused only.
- Generic share flows do not infer advanced semantic structure automatically
beyond the existing preference extractor.
- Captured facts and important dates are local-only and not yet mapped into the
backend sync protocol.
- Inbox triage currently converts one structured draft per inbox item. It does
not yet split a single payload into multiple facts.
- Search is still strongest in People and not yet expanded across facts/dates/
inbox.
## What should happen next in a backend / LLM session
1. Define backend entity contracts for:
- person aliases
- structured facts
- important dates
- shared payload provenance
2. Add sync-envelope mappings for the new local entities.
3. Introduce optional AI-assisted extraction that proposes:
- multiple facts from one payload
- date detection
- confidence scoring
- recommendation context packaging
4. Add server-compatible merge/reconciliation rules for conflicting profile
facts and dates.
5. Implement explicit privacy controls for any future cloud or AI sharing.
## Manual walkthrough
### 1. Create person
- Open `People`.
- Tap `Add person`.
- Enter:
- `Name`
- `Relationship`
- optional `Aliases`
- optional notes/tags/location
- Save.
### 2. Share text into app
- On Android, share any text snippet into the app from another app.
- Or use `Settings` -> `Simulate Share Capture`.
- The review sheet should open showing the parsed text/URL preview.
### 3. Resolve ambiguity
- In the review sheet, choose a fact type such as `Like` or `Note`.
- Pick an existing person from recent chips or the full dropdown.
- Save to that person.
### 4. Save to inbox
- Repeat with another shared snippet.
- In the review sheet, choose `Save To Inbox`.
- Open `Share Inbox`.
### 5. Assign later
- In `Share Inbox`, open the queued item.
- Confirm or change the fact mapping.
- Choose `Review & Save` to match an existing person, or `Create Person`.
- Complete the flow.
### 6. Edit captured fact
- Open `People`.
- Select the person.
- In `Captured facts`, tap `Edit` on the new fact.
- Change the text/label/type or sensitive flag and save.
### 7. Edit captured date
- If the capture was saved as a date, open `Important dates`.
- Tap `Edit`.
- Adjust label/classification/date and save.
+50
View File
@@ -0,0 +1,50 @@
# Open Tasks / Deferred Ideas
Purpose: collect tasks and ideas that should be implemented later so future
sessions can pick them up without losing context.
## Backlog
### iOS Share Sheet Integration Verification
Status: `partially implemented, needs device verification`
Priority: `high` (for real cross-app sharing UX on iPhone/iPad)
Problem
- The repository now contains native iOS Share Extension and Save Action target
files, and the Xcode project references those targets.
- The remaining work is to verify the end-to-end behavior on real devices and
keep CocoaPods/Xcode target wiring healthy as dependencies change.
Current implementation touchpoints
- `ios/Share Extension/`
- `ios/Save Action/`
- `ios/Runner.xcodeproj/project.pbxproj`
- `ios/Runner/Info.plist`
- `lib/features/share_intake/presentation/incoming_share_listener.dart`
Verification plan
1. Run `flutter pub get` and `pod install` from `ios/` when native dependency
versions change.
2. Open the workspace in Xcode and verify Runner, Share Extension, and Save
Action signing/App Group capabilities.
3. Confirm `APP_GROUP_ID` / `CUSTOM_GROUP_ID` resolves consistently for all
targets.
4. Verify build phase ordering (`Embed Foundation Extension` before `Thin Binary`)
if Xcode reports extension embed issues.
5. Test text sharing from iOS Simulator apps such as Notes and Safari.
6. Test real WhatsApp/iMessage sharing on a physical iPhone. Simulator behavior
is not a reliable substitute for chat metadata behavior.
7. Confirm imported shares reach either the review sheet or Share Inbox.
Notes
- The app already has an in-app fallback path for testing ingest logic:
`Settings` -> `Simulate WhatsApp Share`.
- Flutter-side ingest/parsing/inbox routing is implemented. See
`docs/SHARE_INTAKE.md` for the current pipeline.
## Future Ideas (placeholder)
- Add "Promote confirmed preference signal to profile tag" action
- Add duplicate-tag conflict handling when promoting signals
- Add graph explorer minimap / fit-to-screen controls
+1 -1
View File
@@ -1,6 +1,6 @@
# Relationship Saver Progress Log
Updated: 2026-02-22
Updated: 2026-02-23
## Collaboration Rule (Carry Forward)
+2 -1
View File
@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
platform :ios, '14.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -29,6 +29,7 @@ flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
use_modular_headers!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
+39 -1
View File
@@ -1,4 +1,6 @@
PODS:
- battery_plus (1.0.0):
- Flutter
- connectivity_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
@@ -7,21 +9,43 @@ PODS:
- flutter_secure_storage_darwin (10.0.0):
- Flutter
- FlutterMacOS
- package_info_plus (0.4.5):
- Flutter
- receive_sharing_intent (1.8.1):
- Flutter
- Sentry/HybridSDK (8.46.0)
- sentry_flutter (8.14.2):
- Flutter
- FlutterMacOS
- Sentry/HybridSDK (= 8.46.0)
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
- workmanager_apple (0.0.1):
- Flutter
DEPENDENCIES:
- battery_plus (from `.symlinks/plugins/battery_plus/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- Flutter (from `Flutter`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
SPEC REPOS:
trunk:
- Sentry
EXTERNAL SOURCES:
battery_plus:
:path: ".symlinks/plugins/battery_plus/ios"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
Flutter:
@@ -30,19 +54,33 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_secure_storage_darwin:
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios"
sentry_flutter:
:path: ".symlinks/plugins/sentry_flutter/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
workmanager_apple:
:path: ".symlinks/plugins/workmanager_apple/ios"
SPEC CHECKSUMS:
battery_plus: b42253f6d2dde71712f8c36fef456d99121c5977
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
PODFILE CHECKSUM: 1959d098c91d8a792531a723c4a9d7e9f6a01e38
COCOAPODS: 1.16.2
+393 -14
View File
@@ -17,6 +17,11 @@
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 */; };
A1B2C3021111111111111111 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C30D1111111111111111 /* ShareViewController.swift */; };
A1B2C3031111111111111111 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A1B2C3181111111111111111 /* MainInterface.storyboard */; };
A1B2C3041111111111111111 /* Share Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = A1B2C30C1111111111111111 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
B2C3D4012222222222222222 /* SaveActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4062222222222222222 /* SaveActionViewController.swift */; };
B2C3D4022222222222222222 /* Save Action.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = B2C3D4052222222222222222 /* Save Action.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -27,6 +32,20 @@
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
A1B2C3051111111111111111 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = A1B2C3131111111111111111;
remoteInfo = "Share Extension";
};
B2C3D4042222222222222222 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = B2C3D40B2222222222222222;
remoteInfo = "Save Action";
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -40,6 +59,18 @@
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
A1B2C3061111111111111111 /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
A1B2C3041111111111111111 /* Share Extension.appex in Embed App Extensions */,
B2C3D4022222222222222222 /* Save Action.appex in Embed App Extensions */,
);
name = "Embed App Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
@@ -64,6 +95,16 @@
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
9B7CFEE2CE4B7B1DCB85B3E1 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
A1B2C30A1111111111111111 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
A1B2C30C1111111111111111 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
A1B2C30D1111111111111111 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
A1B2C30E1111111111111111 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; };
A1B2C30F1111111111111111 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A1B2C3101111111111111111 /* Share Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Share Extension.entitlements"; sourceTree = "<group>"; };
B2C3D4052222222222222222 /* Save Action.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Save Action.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
B2C3D4062222222222222222 /* SaveActionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveActionViewController.swift; sourceTree = "<group>"; };
B2C3D4072222222222222222 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
B2C3D4082222222222222222 /* Save Action.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Save Action.entitlements"; sourceTree = "<group>"; };
CD3F9117486EC23DF50E2D6A /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
E9B506D809ABF2A50BF6135B /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
F2C7EE505B336533B62B23EB /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
@@ -78,6 +119,20 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
A1B2C3111111111111111111 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
B2C3D4092222222222222222 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
E2BAEB33085D4BFE88A55D45 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -107,7 +162,6 @@
9708213885153303B111D6D9 /* Pods-RunnerTests.release.xcconfig */,
6881917E7D40106AEFE87622 /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
@@ -136,6 +190,8 @@
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
A1B2C3121111111111111111 /* Share Extension */,
B2C3D40A2222222222222222 /* Save Action */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
3D195739A0A78D037BF244F8 /* Pods */,
@@ -147,6 +203,8 @@
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
A1B2C30C1111111111111111 /* Share Extension.appex */,
B2C3D4052222222222222222 /* Save Action.appex */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
@@ -155,6 +213,7 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
A1B2C30A1111111111111111 /* Runner.entitlements */,
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
@@ -168,6 +227,27 @@
path = Runner;
sourceTree = "<group>";
};
A1B2C3121111111111111111 /* Share Extension */ = {
isa = PBXGroup;
children = (
A1B2C3101111111111111111 /* Share Extension.entitlements */,
A1B2C30D1111111111111111 /* ShareViewController.swift */,
A1B2C3181111111111111111 /* MainInterface.storyboard */,
A1B2C30F1111111111111111 /* Info.plist */,
);
path = "Share Extension";
sourceTree = "<group>";
};
B2C3D40A2222222222222222 /* Save Action */ = {
isa = PBXGroup;
children = (
B2C3D4082222222222222222 /* Save Action.entitlements */,
B2C3D4062222222222222222 /* SaveActionViewController.swift */,
B2C3D4072222222222222222 /* Info.plist */,
);
path = "Save Action";
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -200,18 +280,55 @@
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
A1B2C3061111111111111111 /* Embed App Extensions */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
6F8015E3E410C301706C3C43 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
A1B2C3171111111111111111 /* PBXTargetDependency */,
B2C3D40E2222222222222222 /* PBXTargetDependency */,
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
A1B2C3131111111111111111 /* Share Extension */ = {
isa = PBXNativeTarget;
buildConfigurationList = A1B2C31C1111111111111111 /* Build configuration list for PBXNativeTarget "Share Extension" */;
buildPhases = (
A1B2C3161111111111111111 /* Sources */,
A1B2C3111111111111111111 /* Frameworks */,
A1B2C3141111111111111111 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = "Share Extension";
productName = "Share Extension";
productReference = A1B2C30C1111111111111111 /* Share Extension.appex */;
productType = "com.apple.product-type.app-extension";
};
B2C3D40B2222222222222222 /* Save Action */ = {
isa = PBXNativeTarget;
buildConfigurationList = B2C3D4122222222222222222 /* Build configuration list for PBXNativeTarget "Save Action" */;
buildPhases = (
B2C3D40D2222222222222222 /* Sources */,
B2C3D4092222222222222222 /* Frameworks */,
B2C3D40C2222222222222222 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = "Save Action";
productName = "Save Action";
productReference = B2C3D4052222222222222222 /* Save Action.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -230,6 +347,12 @@
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
A1B2C3131111111111111111 = {
CreatedOnToolsVersion = 15.0.1;
};
B2C3D40B2222222222222222 = {
CreatedOnToolsVersion = 15.0.1;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
@@ -246,6 +369,8 @@
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
A1B2C3131111111111111111 /* Share Extension */,
B2C3D40B2222222222222222 /* Save Action */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
@@ -270,6 +395,21 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
A1B2C3141111111111111111 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3031111111111111111 /* MainInterface.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
B2C3D40C2222222222222222 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -386,6 +526,22 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
A1B2C3161111111111111111 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3021111111111111111 /* ShareViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
B2C3D40D2222222222222222 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B2C3D4012222222222222222 /* SaveActionViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@@ -394,6 +550,16 @@
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
A1B2C3171111111111111111 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = A1B2C3131111111111111111 /* Share Extension */;
targetProxy = A1B2C3051111111111111111 /* PBXContainerItemProxy */;
};
B2C3D40E2222222222222222 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = B2C3D40B2222222222222222 /* Save Action */;
targetProxy = B2C3D4042222222222222222 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
@@ -413,6 +579,14 @@
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
A1B2C3181111111111111111 /* MainInterface.storyboard */ = {
isa = PBXVariantGroup;
children = (
A1B2C30E1111111111111111 /* Base */,
);
name = MainInterface.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
@@ -420,6 +594,11 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
APP_BUNDLE_IDENTIFIER = com.rijadzuzo.relationshipSaver;
APP_ACTION_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).SaveAction";
APP_GROUP_ID = group.com.rijadzuzo.relationshipSaver.shared;
APP_RUNNER_TESTS_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests";
APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShareExtension";
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
@@ -448,6 +627,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM_ID = "";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -459,7 +639,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -474,15 +654,17 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = Y98YFL3B2W;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1;
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = SR3UKQVKP6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.relationshipSaver;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -499,7 +681,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.relationshipSaver.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_RUNNER_TESTS_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -517,7 +699,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.relationshipSaver.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_RUNNER_TESTS_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -533,7 +715,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.relationshipSaver.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_RUNNER_TESTS_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -544,6 +726,11 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
APP_BUNDLE_IDENTIFIER = com.rijadzuzo.relationshipSaver;
APP_ACTION_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).SaveAction";
APP_GROUP_ID = group.com.rijadzuzo.relationshipSaver.shared;
APP_RUNNER_TESTS_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests";
APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShareExtension";
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
@@ -572,6 +759,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM_ID = "";
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -589,7 +777,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -601,6 +789,11 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
APP_BUNDLE_IDENTIFIER = com.rijadzuzo.relationshipSaver;
APP_ACTION_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).SaveAction";
APP_GROUP_ID = group.com.rijadzuzo.relationshipSaver.shared;
APP_RUNNER_TESTS_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests";
APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShareExtension";
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
@@ -629,6 +822,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM_ID = "";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -640,7 +834,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -657,15 +851,17 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = Y98YFL3B2W;
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = SR3UKQVKP6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.relationshipSaver;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -680,15 +876,17 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = Y98YFL3B2W;
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = SR3UKQVKP6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.relationshipSaver;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -696,6 +894,167 @@
};
name = Release;
};
A1B2C3191111111111111111 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "Share Extension/Share Extension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = SR3UKQVKP6;
INFOPLIST_FILE = "Share Extension/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Relationship Saver";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
A1B2C31A1111111111111111 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "Share Extension/Share Extension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = SR3UKQVKP6;
INFOPLIST_FILE = "Share Extension/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Relationship Saver";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
A1B2C31B1111111111111111 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "Share Extension/Share Extension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = SR3UKQVKP6;
INFOPLIST_FILE = "Share Extension/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Relationship Saver";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Profile;
};
B2C3D40F2222222222222222 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "Save Action/Save Action.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = SR3UKQVKP6;
INFOPLIST_FILE = "Save Action/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Save to Relationship Saver";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_ACTION_EXTENSION_BUNDLE_IDENTIFIER)";
PRODUCT_MODULE_NAME = SaveAction;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
B2C3D4102222222222222222 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "Save Action/Save Action.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = SR3UKQVKP6;
INFOPLIST_FILE = "Save Action/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Save to Relationship Saver";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_ACTION_EXTENSION_BUNDLE_IDENTIFIER)";
PRODUCT_MODULE_NAME = SaveAction;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
B2C3D4112222222222222222 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "Save Action/Save Action.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = SR3UKQVKP6;
INFOPLIST_FILE = "Save Action/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Save to Relationship Saver";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_ACTION_EXTENSION_BUNDLE_IDENTIFIER)";
PRODUCT_MODULE_NAME = SaveAction;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Profile;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -729,6 +1088,26 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
A1B2C31C1111111111111111 /* Build configuration list for PBXNativeTarget "Share Extension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A1B2C3191111111111111111 /* Debug */,
A1B2C31A1111111111111111 /* Release */,
A1B2C31B1111111111111111 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
B2C3D4122222222222222222 /* Build configuration list for PBXNativeTarget "Save Action" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B2C3D40F2222222222222222 /* Debug */,
B2C3D4102222222222222222 /* Release */,
B2C3D4112222222222222222 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
@@ -52,9 +52,9 @@
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
buildConfiguration = "Release"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
+5
View File
@@ -1,5 +1,6 @@
import Flutter
import UIKit
import workmanager_apple
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@@ -7,6 +8,10 @@ import UIKit
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
WorkmanagerPlugin.registerPeriodicTask(
withIdentifier: "com.relationshipsaver.llm.digest",
frequency: NSNumber(value: 7 * 24 * 60 * 60)
)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
+31
View File
@@ -2,6 +2,12 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.relationshipsaver.llm.digest</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
@@ -22,10 +28,30 @@
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>Relationship Saver can connect to your local Ollama server for private AI suggestions.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Relationship Saver can import shared media and links you send into the app.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
@@ -49,6 +75,11 @@
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>$(CUSTOM_GROUP_ID)</string>
</array>
</dict>
</plist>
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>CFBundleDisplayName</key>
<string>Save to Relationship Saver</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.ui-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).SaveActionViewController</string>
</dict>
</dict>
</plist>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>$(CUSTOM_GROUP_ID)</string>
</array>
</dict>
</plist>
@@ -0,0 +1,194 @@
import MobileCoreServices
import UniformTypeIdentifiers
import UIKit
private let schemePrefix = "ShareMedia"
private let userDefaultsKey = "ShareKey"
private let userDefaultsMessageKey = "ShareMessageKey"
private let appGroupIdKey = "AppGroupId"
private struct SharedMediaFile: Codable {
let path: String
let mimeType: String?
let thumbnail: String?
let duration: Double?
let message: String?
let type: SharedMediaType
}
private enum SharedMediaType: String, Codable {
case text
case url
}
final class SaveActionViewController: UIViewController {
private var hostAppBundleIdentifier = ""
private var appGroupId = ""
private var sharedMedia: [SharedMediaFile] = []
private let group = DispatchGroup()
private var didStart = false
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard !didStart else {
return
}
didStart = true
loadIdentifiers()
loadSharedContent()
}
private func loadIdentifiers() {
let actionExtensionIdentifier = Bundle.main.bundleIdentifier ?? ""
if let lastDotIndex = actionExtensionIdentifier.lastIndex(of: ".") {
hostAppBundleIdentifier = String(actionExtensionIdentifier[..<lastDotIndex])
} else {
hostAppBundleIdentifier = actionExtensionIdentifier
}
let defaultAppGroupId = "group.\(hostAppBundleIdentifier)"
appGroupId =
Bundle.main.object(forInfoDictionaryKey: appGroupIdKey) as? String
?? defaultAppGroupId
}
private func loadSharedContent() {
guard let inputItems = extensionContext?.inputItems as? [NSExtensionItem] else {
finish()
return
}
for item in inputItems {
appendAttributedText(item.attributedContentText)
for provider in item.attachments ?? [] {
load(provider)
}
}
group.notify(queue: .main) { [weak self] in
self?.finish()
}
}
private func load(_ provider: NSItemProvider) {
if provider.hasItemConformingToTypeIdentifier(urlTypeIdentifier) {
group.enter()
provider.loadItem(forTypeIdentifier: urlTypeIdentifier, options: nil) { [weak self] item, _ in
defer {
self?.group.leave()
}
if let url = item as? URL {
self?.appendLiteral(url.absoluteString, type: .url, mimeType: nil)
} else if let text = item as? String {
self?.appendLiteral(text, type: .url, mimeType: nil)
}
}
return
}
if provider.hasItemConformingToTypeIdentifier(textTypeIdentifier) {
group.enter()
provider.loadItem(forTypeIdentifier: textTypeIdentifier, options: nil) { [weak self] item, _ in
defer {
self?.group.leave()
}
if let text = item as? String {
self?.appendLiteral(text, type: .text, mimeType: "text/plain")
}
}
}
}
private var textTypeIdentifier: String {
if #available(iOS 14.0, *) {
return UTType.text.identifier
}
return kUTTypeText as String
}
private var urlTypeIdentifier: String {
if #available(iOS 14.0, *) {
return UTType.url.identifier
}
return kUTTypeURL as String
}
private func appendAttributedText(_ text: NSAttributedString?) {
guard let value = text?.string else {
return
}
appendLiteral(value, type: .text, mimeType: "text/plain")
}
private func appendLiteral(_ value: String, type: SharedMediaType, mimeType: String?) {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return
}
guard !sharedMedia.contains(where: { $0.type == type && $0.path == trimmed }) else {
return
}
sharedMedia.append(
SharedMediaFile(
path: trimmed,
mimeType: mimeType,
thumbnail: nil,
duration: nil,
message: nil,
type: type
)
)
}
private func finish() {
guard !sharedMedia.isEmpty else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
saveAndRedirect()
}
private func saveAndRedirect() {
guard let encodedData = try? JSONEncoder().encode(sharedMedia) else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
let userDefaults = UserDefaults(suiteName: appGroupId)
userDefaults?.set(encodedData, forKey: userDefaultsKey)
userDefaults?.set(nil, forKey: userDefaultsMessageKey)
userDefaults?.synchronize()
redirectToHostApp()
}
private func redirectToHostApp() {
guard let url = URL(string: "\(schemePrefix)-\(hostAppBundleIdentifier):share") else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
var responder: UIResponder? = self
let selectorOpenUrl = sel_registerName("openURL:")
if #available(iOS 18.0, *) {
while responder != nil {
if let application = responder as? UIApplication {
application.open(url, options: [:], completionHandler: nil)
break
}
responder = responder?.next
}
} else {
while responder != nil {
if responder?.responds(to: selectorOpenUrl) == true {
_ = responder?.perform(selectorOpenUrl, with: url)
break
}
responder = responder?.next
}
}
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="j1y-V4-xli">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Share View Controller-->
<scene sceneID="ceB-am-kn3">
<objects>
<viewController id="j1y-V4-xli" customClass="ShareViewController" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" opaque="NO" contentMode="scaleToFill" id="wbc-yd-nQP">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
<viewLayoutGuide key="safeArea" id="1Xd-am-t49"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="CEy-Cv-SGf" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
+52
View File
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>CFBundleDisplayName</key>
<string>Relationship Saver</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
<integer>5</integer>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>20</integer>
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
<integer>10</integer>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
</dict>
<key>PHSupportedMediaTypes</key>
<array>
<string>Video</string>
<string>Image</string>
</array>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>$(CUSTOM_GROUP_ID)</string>
</array>
</dict>
</plist>
@@ -0,0 +1,315 @@
import MobileCoreServices
import Social
import UniformTypeIdentifiers
import UIKit
private let schemePrefix = "ShareMedia"
private let userDefaultsKey = "ShareKey"
private let userDefaultsMessageKey = "ShareMessageKey"
private let appGroupIdKey = "AppGroupId"
private struct SharedMediaFile: Codable {
let path: String
let mimeType: String?
let thumbnail: String?
let duration: Double?
let message: String?
let type: SharedMediaType
}
private enum SharedMediaType: String, Codable, CaseIterable {
case image
case video
case text
case file
case url
var typeIdentifier: String {
if #available(iOS 14.0, *) {
switch self {
case .image:
return UTType.image.identifier
case .video:
return UTType.movie.identifier
case .text:
return UTType.text.identifier
case .file:
return UTType.fileURL.identifier
case .url:
return UTType.url.identifier
}
}
switch self {
case .image:
return kUTTypeImage as String
case .video:
return kUTTypeMovie as String
case .text:
return kUTTypeText as String
case .file:
return kUTTypeFileURL as String
case .url:
return kUTTypeURL as String
}
}
}
final class ShareViewController: SLComposeServiceViewController {
private var hostAppBundleIdentifier = ""
private var appGroupId = ""
private var sharedMedia: [SharedMediaFile] = []
private let group = DispatchGroup()
private var didFinishLoadingAttachments = false
override func viewDidLoad() {
super.viewDidLoad()
loadIdentifiers()
}
override func isContentValid() -> Bool {
true
}
override func didSelectPost() {
appendContentTextIfNeeded()
saveAndRedirect(message: contentText)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard !didFinishLoadingAttachments else {
return
}
didFinishLoadingAttachments = true
loadAttachments()
}
override func configurationItems() -> [Any]! {
[]
}
private func loadIdentifiers() {
let shareExtensionIdentifier = Bundle.main.bundleIdentifier ?? ""
if let lastDotIndex = shareExtensionIdentifier.lastIndex(of: ".") {
hostAppBundleIdentifier = String(shareExtensionIdentifier[..<lastDotIndex])
} else {
hostAppBundleIdentifier = shareExtensionIdentifier
}
let defaultAppGroupId = "group.\(hostAppBundleIdentifier)"
appGroupId =
Bundle.main.object(forInfoDictionaryKey: appGroupIdKey) as? String
?? defaultAppGroupId
}
private func loadAttachments() {
guard
let inputItem = extensionContext?.inputItems.first as? NSExtensionItem,
let attachments = inputItem.attachments
else {
appendContentTextIfNeeded()
saveAndRedirect(message: contentText)
return
}
NSLog("RelationshipSaverShare: loading %d attachment(s)", attachments.count)
for attachment in attachments {
loadAttachment(attachment)
}
group.notify(queue: .main) { [weak self] in
guard let self else {
return
}
self.appendContentTextIfNeeded()
if !self.sharedMedia.isEmpty {
self.saveAndRedirect()
} else {
NSLog("RelationshipSaverShare: no shareable media or text found")
}
}
}
private func loadAttachment(_ attachment: NSItemProvider) {
for type in SharedMediaType.allCases where attachment.hasItemConformingToTypeIdentifier(type.typeIdentifier) {
group.enter()
attachment.loadItem(forTypeIdentifier: type.typeIdentifier, options: nil) { [weak self] item, _ in
defer {
self?.group.leave()
}
self?.appendSharedItem(item, type: type)
}
return
}
}
private func appendSharedItem(_ item: NSSecureCoding?, type: SharedMediaType) {
switch (item, type) {
case let (text as String, .text):
appendLiteral(text, type: .text, mimeType: "text/plain")
case let (url as URL, .url):
appendLiteral(url.absoluteString, type: .url, mimeType: nil)
case let (url as URL, _):
appendFile(url, type: type)
case let (image as UIImage, .image):
appendImage(image)
default:
NSLog("RelationshipSaverShare: unsupported item for type %@", type.rawValue)
break
}
}
private func appendContentTextIfNeeded() {
let trimmed = contentText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return
}
guard !sharedMedia.contains(where: { $0.type == .text && $0.path == trimmed }) else {
return
}
NSLog("RelationshipSaverShare: using compose contentText fallback length=%d", trimmed.count)
appendLiteral(trimmed, type: .text, mimeType: "text/plain")
}
private func appendLiteral(_ value: String, type: SharedMediaType, mimeType: String?) {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return
}
sharedMedia.append(
SharedMediaFile(
path: trimmed,
mimeType: mimeType,
thumbnail: nil,
duration: nil,
message: nil,
type: type
)
)
}
private func appendFile(_ sourceUrl: URL, type: SharedMediaType) {
guard let containerUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else {
return
}
let fileName = sourceUrl.lastPathComponent.isEmpty ? UUID().uuidString : sourceUrl.lastPathComponent
let destinationUrl = containerUrl.appendingPathComponent(fileName)
do {
if FileManager.default.fileExists(atPath: destinationUrl.path) {
try FileManager.default.removeItem(at: destinationUrl)
}
try FileManager.default.copyItem(at: sourceUrl, to: destinationUrl)
sharedMedia.append(
SharedMediaFile(
path: destinationUrl.absoluteString.removingPercentEncoding ?? destinationUrl.absoluteString,
mimeType: sourceUrl.mimeType,
thumbnail: nil,
duration: nil,
message: nil,
type: type
)
)
} catch {
return
}
}
private func appendImage(_ image: UIImage) {
guard
let containerUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId),
let imageData = image.pngData()
else {
return
}
let destinationUrl = containerUrl.appendingPathComponent("\(UUID().uuidString).png")
do {
try imageData.write(to: destinationUrl)
sharedMedia.append(
SharedMediaFile(
path: destinationUrl.absoluteString.removingPercentEncoding ?? destinationUrl.absoluteString,
mimeType: "image/png",
thumbnail: nil,
duration: nil,
message: nil,
type: .image
)
)
} catch {
return
}
}
private func saveAndRedirect(message: String? = nil) {
guard let encodedData = try? JSONEncoder().encode(sharedMedia) else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
let userDefaults = UserDefaults(suiteName: appGroupId)
userDefaults?.set(encodedData, forKey: userDefaultsKey)
userDefaults?.set(message, forKey: userDefaultsMessageKey)
userDefaults?.synchronize()
NSLog("RelationshipSaverShare: saved %d shared item(s), appGroup=%@", sharedMedia.count, appGroupId)
redirectToHostApp()
}
private func redirectToHostApp() {
loadIdentifiers()
guard let url = URL(string: "\(schemePrefix)-\(hostAppBundleIdentifier):share") else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
var responder: UIResponder? = self
let selectorOpenUrl = sel_registerName("openURL:")
if #available(iOS 18.0, *) {
while responder != nil {
if let application = responder as? UIApplication {
application.open(url, options: [:], completionHandler: nil)
break
}
responder = responder?.next
}
} else {
while responder != nil {
if responder?.responds(to: selectorOpenUrl) == true {
_ = responder?.perform(selectorOpenUrl, with: url)
break
}
responder = responder?.next
}
}
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}
private extension URL {
var mimeType: String {
if #available(iOS 14.0, *),
let mimeType = UTType(filenameExtension: pathExtension)?.preferredMIMEType
{
return mimeType
}
guard
let identifier = UTTypeCreatePreferredIdentifierForTag(
kUTTagClassFilenameExtension,
pathExtension as NSString,
nil
)?.takeRetainedValue(),
let mimeType = UTTypeCopyPreferredTagWithClass(identifier, kUTTagClassMIMEType)?.takeRetainedValue()
else {
return "application/octet-stream"
}
return mimeType as String
}
}
+11
View File
@@ -0,0 +1,11 @@
# Lib Layout
This app now uses a prototype-friendly vertical-slice structure.
Use `lib/app/` for bootstrap, shell navigation, shared app-state wiring, and
local persistence. Use `lib/features/<slice>/` for product work inside a
single slice. Use `lib/core/` only for cross-cutting primitives that are not
owned by one feature.
If you are starting work, open the slice README first. Most old top-level
files are now compatibility exports that point to the new canonical location.
+12
View File
@@ -0,0 +1,12 @@
# App Layer
`lib/app/` holds app-level code that is not one product slice:
- `relationship_saver_app.dart`: root widget and global listeners.
- `navigation/`: typed shell destinations.
- `presentation/`: responsive app shell.
- `data/`: aggregate prototype repository and persistence plumbing.
- `state/`: shared aggregate snapshot used by the current local-first store.
This is the place to change boot flow, shell behavior, or prototype-wide state
composition.
+15
View File
@@ -0,0 +1,15 @@
# App Data
This folder contains the prototype app store and persistence adapters.
`relationship_repository.dart` is the single local-first write boundary for the
prototype. It now delegates behavior into part files so the code is grouped by
slice instead of one giant file:
- `relationship_repository_people.dart`: people CRUD and profile merging.
- `relationship_repository_share.dart`: share intake, inbox, and person resolution.
- `relationship_repository_activity.dart`: captures, facts, ideas, reminders, and task toggles.
- `relationship_repository_sync.dart`: sync envelopes and remote-change application.
If you are changing persistence behavior, start with the matching part file and
only touch the root repository file when the shared lifecycle changes.
+176
View File
@@ -0,0 +1,176 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/app/data/storage/local_data_store.dart';
import 'package:relationship_saver/app/data/storage/local_data_store_hive.dart';
import 'package:relationship_saver/app/data/storage/local_data_store_provider.dart';
import 'package:relationship_saver/app/data/storage/local_data_store_shared_prefs.dart';
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:relationship_saver/features/dashboard/domain/dashboard_models.dart';
import 'package:relationship_saver/features/ideas/domain/idea_models.dart';
import 'package:relationship_saver/features/local/chat_preference_extractor.dart';
import 'package:relationship_saver/features/moments/domain/moment_models.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
import 'package:relationship_saver/features/reminders/application/reminder_scheduler_provider.dart';
import 'package:relationship_saver/features/reminders/domain/reminder_models.dart';
import 'package:relationship_saver/features/share_intake/application/share_capture_models.dart';
import 'package:relationship_saver/features/share_intake/domain/share_capture_draft_suggester.dart';
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
import 'package:relationship_saver/features/sync/data/sync_queue_repository.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
import 'package:uuid/uuid.dart';
part 'relationship_repository_activity.dart';
part 'relationship_repository_ai_digest.dart';
part 'relationship_repository_people.dart';
part 'relationship_repository_share.dart';
part 'relationship_repository_sync.dart';
const int _schemaVersion = 5;
const int _syncSchemaVersion = 1;
const Uuid _uuid = Uuid();
const ChatPreferenceExtractor _chatPreferenceExtractor =
ChatPreferenceExtractor();
/// Persisted local-first app store used by the current prototype.
///
/// The storage backend is still a single local record because that keeps the
/// prototype stable, but the repository implementation is now split by feature
/// behavior so it reads like vertical slices instead of one monolithic file.
class LocalRepository extends AsyncNotifier<LocalDataState> {
@override
Future<LocalDataState> build() async {
final LocalDataStore store = ref.read(localDataStoreProvider);
LocalDataRecord? record = await store.read();
if ((record == null || record.rawState.isEmpty) &&
AppConfig.useHiveLocalDb &&
store is HiveLocalDataStore) {
final SharedPrefsLocalDataStore legacyStore = SharedPrefsLocalDataStore();
final LocalDataRecord? legacy = await legacyStore.read();
if (legacy != null && legacy.rawState.isNotEmpty) {
await store.write(
rawState: legacy.rawState,
schemaVersion: legacy.schemaVersion,
);
await legacyStore.clear();
record = await store.read();
}
}
if (record == null || record.rawState.isEmpty) {
final LocalDataState seeded = LocalDataState.seed();
await _persist(seeded);
await _reconcileReminderSchedule(seeded);
return seeded;
}
final LocalDataRecord migrated = await _migrateIfNeeded(record);
try {
final Map<String, dynamic> json =
jsonDecode(migrated.rawState) as Map<String, dynamic>;
final LocalDataState loaded = LocalDataState.fromJson(json);
await _reconcileReminderSchedule(loaded);
return loaded;
} on FormatException {
final LocalDataState fallback = LocalDataState.seed();
await _persist(fallback);
await _reconcileReminderSchedule(fallback);
return fallback;
}
}
Future<void> _setState(LocalDataState next) async {
state = AsyncData<LocalDataState>(next);
await _persist(next);
await _reconcileReminderSchedule(next);
}
LocalDataState _requireState() {
final LocalDataState? value = state.asData?.value;
if (value == null) {
throw StateError('Local data state is not ready yet');
}
return value;
}
Future<void> _persist(LocalDataState state) async {
await ref
.read(localDataStoreProvider)
.write(
rawState: jsonEncode(state.toJson()),
schemaVersion: _schemaVersion,
);
}
Future<void> _reconcileReminderSchedule(LocalDataState state) async {
try {
await ref.read(reminderSchedulerProvider).reconcile(state.reminders);
} catch (_) {
// Scheduling failures must not block local-first data updates.
}
}
Future<LocalDataRecord> _migrateIfNeeded(LocalDataRecord record) async {
final int currentVersion = record.schemaVersion;
if (currentVersion == _schemaVersion) {
return record;
}
final LocalDataStore store = ref.read(localDataStoreProvider);
if (currentVersion <= 0) {
final LocalDataState seeded = LocalDataState.seed();
await store.clear();
await _persist(seeded);
return LocalDataRecord(
rawState: jsonEncode(seeded.toJson()),
schemaVersion: _schemaVersion,
);
}
try {
final Map<String, dynamic> json =
jsonDecode(record.rawState) as Map<String, dynamic>;
final LocalDataState migratedState = LocalDataState.fromJson(json);
final String migratedRawState = jsonEncode(migratedState.toJson());
await store.write(
rawState: migratedRawState,
schemaVersion: _schemaVersion,
);
return LocalDataRecord(
rawState: migratedRawState,
schemaVersion: _schemaVersion,
);
} on FormatException {
final LocalDataState seeded = LocalDataState.seed();
await store.clear();
await _persist(seeded);
return LocalDataRecord(
rawState: jsonEncode(seeded.toJson()),
schemaVersion: _schemaVersion,
);
}
}
Future<void> _enqueueChange(ChangeEnvelope change) async {
await ref.read(syncQueueRepositoryProvider.notifier).enqueue(change);
}
Future<void> _enqueueChanges(List<ChangeEnvelope> changes) async {
await ref.read(syncQueueRepositoryProvider.notifier).enqueueAll(changes);
}
}
String? _trimToNull(String? value) {
if (value == null) {
return null;
}
final String trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
final AsyncNotifierProvider<LocalRepository, LocalDataState>
localRepositoryProvider =
AsyncNotifierProvider<LocalRepository, LocalDataState>(LocalRepository.new);
@@ -0,0 +1,667 @@
part of 'relationship_repository.dart';
/// Capture, idea, reminder, and lightweight dashboard state persistence.
extension LocalRepositoryActivityOperations on LocalRepository {
Future<void> addMoment({
required String personId,
required String summary,
String type = 'capture',
}) async {
final String normalized = summary.trim();
if (normalized.isEmpty) {
throw ArgumentError('Capture summary cannot be empty');
}
final LocalDataState current = _requireState();
final String payload = _truncate(normalized, 280);
final RelationshipMoment moment = RelationshipMoment(
id: 'm-${_uuid.v4()}',
personId: personId,
title: _titleFromSummary(payload),
summary: payload,
at: DateTime.now(),
type: type,
);
await _setState(
current.copyWith(
moments: <RelationshipMoment>[moment, ...current.moments],
),
);
await _enqueueChange(
_buildEnvelope(
entityType: 'capture',
entityId: moment.id,
op: ChangeOperation.upsert,
payload: _momentPayload(moment),
),
);
}
Future<void> addPersonFact({
required String personId,
required CapturedFactType type,
required String text,
String? label,
CaptureSourceKind sourceKind = CaptureSourceKind.manual,
String? sourceApp,
String? sourceUrl,
String? sharedMessageId,
String? inboxEntryId,
double? confidence,
bool needsReview = false,
bool isSensitive = false,
DateTime? createdAt,
}) async {
final LocalDataState current = _requireState();
final String normalizedText = text.trim();
if (normalizedText.isEmpty) {
throw ArgumentError('Fact text is required');
}
final DateTime now = createdAt ?? DateTime.now();
final PersonFact fact = PersonFact(
id: 'pf-${_uuid.v4()}',
personId: personId,
type: type,
text: normalizedText,
label: _trimToNull(label),
sourceKind: sourceKind,
sourceApp: _trimToNull(sourceApp),
sourceUrl: _trimToNull(sourceUrl),
sharedMessageId: sharedMessageId,
inboxEntryId: inboxEntryId,
confidence: confidence,
needsReview: needsReview,
isSensitive: isSensitive,
createdAt: now,
updatedAt: now,
);
await _setState(
current.copyWith(
personFacts: <PersonFact>[fact, ...current.personFacts],
people: _touchPeople(
current.people,
personId: personId,
updatedAt: now,
interactedAt: now,
),
),
);
}
Future<void> updatePersonFact(PersonFact fact) async {
final LocalDataState current = _requireState();
final DateTime now = DateTime.now();
final List<PersonFact> updated = current.personFacts
.map(
(PersonFact item) =>
item.id == fact.id ? fact.copyWith(updatedAt: now) : item,
)
.toList(growable: false);
await _setState(
current.copyWith(
personFacts: updated,
people: _touchPeople(
current.people,
personId: fact.personId,
updatedAt: now,
),
),
);
}
Future<void> deletePersonFact(String factId) async {
final LocalDataState current = _requireState();
final List<PersonFact> updated = current.personFacts
.where((PersonFact item) => item.id != factId)
.toList(growable: false);
await _setState(current.copyWith(personFacts: updated));
}
Future<void> addImportantDate({
required String personId,
required String label,
required DateTime date,
String classification = 'important',
CaptureSourceKind sourceKind = CaptureSourceKind.manual,
String? sourceApp,
String? inboxEntryId,
bool isSensitive = false,
DateTime? createdAt,
}) async {
final LocalDataState current = _requireState();
final String normalizedLabel = label.trim();
if (normalizedLabel.isEmpty) {
throw ArgumentError('Date label is required');
}
final DateTime now = createdAt ?? DateTime.now();
final PersonImportantDate importantDate = PersonImportantDate(
id: 'pd-${_uuid.v4()}',
personId: personId,
label: normalizedLabel,
date: date,
classification: classification.trim().isEmpty
? 'important'
: classification.trim(),
sourceKind: sourceKind,
sourceApp: _trimToNull(sourceApp),
inboxEntryId: inboxEntryId,
isSensitive: isSensitive,
createdAt: now,
updatedAt: now,
);
await _setState(
current.copyWith(
importantDates: <PersonImportantDate>[
importantDate,
...current.importantDates,
],
people: _touchPeople(
current.people,
personId: personId,
updatedAt: now,
interactedAt: now,
),
),
);
}
Future<void> updateImportantDate(PersonImportantDate value) async {
final LocalDataState current = _requireState();
final DateTime now = DateTime.now();
final List<PersonImportantDate> updated = current.importantDates
.map(
(PersonImportantDate item) =>
item.id == value.id ? value.copyWith(updatedAt: now) : item,
)
.toList(growable: false);
await _setState(
current.copyWith(
importantDates: updated,
people: _touchPeople(
current.people,
personId: value.personId,
updatedAt: now,
),
),
);
}
Future<void> deleteImportantDate(String dateId) async {
final LocalDataState current = _requireState();
final List<PersonImportantDate> updated = current.importantDates
.where((PersonImportantDate item) => item.id != dateId)
.toList(growable: false);
await _setState(current.copyWith(importantDates: updated));
}
Future<void> updateMoment(RelationshipMoment moment) async {
if (moment.summary.trim().isEmpty) {
throw ArgumentError('Capture summary cannot be empty');
}
final LocalDataState current = _requireState();
final List<RelationshipMoment> updated = current.moments
.map((RelationshipMoment item) => item.id == moment.id ? moment : item)
.toList(growable: false);
await _setState(current.copyWith(moments: updated));
await _enqueueChange(
_buildEnvelope(
entityType: 'capture',
entityId: moment.id,
op: ChangeOperation.upsert,
payload: _momentPayload(moment),
),
);
}
Future<void> deleteMoment(String momentId) async {
final LocalDataState current = _requireState();
final List<RelationshipMoment> moments = current.moments
.where((RelationshipMoment moment) => moment.id != momentId)
.toList(growable: false);
await _setState(current.copyWith(moments: moments));
await _enqueueChange(
_buildEnvelope(
entityType: 'capture',
entityId: momentId,
op: ChangeOperation.delete,
),
);
}
Future<void> addIdea({
required IdeaType type,
required String title,
required String details,
String? personId,
}) async {
final String normalizedTitle = title.trim();
if (normalizedTitle.isEmpty) {
throw ArgumentError('Idea title is required');
}
final LocalDataState current = _requireState();
final RelationshipIdea idea = RelationshipIdea(
id: 'i-${_uuid.v4()}',
personId: personId,
type: type,
title: normalizedTitle,
details: details.trim(),
createdAt: DateTime.now(),
);
await _setState(
current.copyWith(ideas: <RelationshipIdea>[idea, ...current.ideas]),
);
await _enqueueChange(
_buildEnvelope(
entityType: _ideaEntityType(idea.type),
entityId: idea.id,
op: ChangeOperation.upsert,
payload: _ideaPayload(idea),
),
);
}
Future<void> updateIdea(RelationshipIdea idea) async {
if (idea.title.trim().isEmpty) {
throw ArgumentError('Idea title is required');
}
final LocalDataState current = _requireState();
final List<RelationshipIdea> ideas = current.ideas
.map((RelationshipIdea item) => item.id == idea.id ? idea : item)
.toList(growable: false);
await _setState(current.copyWith(ideas: ideas));
await _enqueueChange(
_buildEnvelope(
entityType: _ideaEntityType(idea.type),
entityId: idea.id,
op: ChangeOperation.upsert,
payload: _ideaPayload(idea),
),
);
}
Future<void> toggleIdeaArchived(String ideaId) async {
final LocalDataState current = _requireState();
RelationshipIdea? updatedIdea;
final List<RelationshipIdea> ideas = current.ideas
.map((RelationshipIdea item) {
if (item.id != ideaId) {
return item;
}
final RelationshipIdea toggled = item.copyWith(
isArchived: !item.isArchived,
);
updatedIdea = toggled;
return toggled;
})
.toList(growable: false);
await _setState(current.copyWith(ideas: ideas));
if (updatedIdea != null) {
await _enqueueChange(
_buildEnvelope(
entityType: _ideaEntityType(updatedIdea!.type),
entityId: updatedIdea!.id,
op: ChangeOperation.upsert,
payload: _ideaPayload(updatedIdea!),
),
);
}
}
Future<void> deleteIdea(String ideaId) async {
final LocalDataState current = _requireState();
final List<RelationshipIdea> ideas = current.ideas
.where((RelationshipIdea idea) => idea.id != ideaId)
.toList(growable: false);
await _setState(current.copyWith(ideas: ideas));
await _enqueueChange(
_buildEnvelope(
entityType: _ideaEntityTypeFromId(current.ideas, ideaId),
entityId: ideaId,
op: ChangeOperation.delete,
),
);
}
Future<void> addReminder({
required String title,
required ReminderCadence cadence,
required DateTime nextAt,
String? personId,
}) async {
final String normalizedTitle = title.trim();
if (normalizedTitle.isEmpty) {
throw ArgumentError('Reminder title is required');
}
final LocalDataState current = _requireState();
final ReminderRule reminder = ReminderRule(
id: 'r-${_uuid.v4()}',
personId: personId,
title: normalizedTitle,
cadence: cadence,
nextAt: nextAt,
enabled: true,
);
await _setState(
current.copyWith(
reminders: <ReminderRule>[reminder, ...current.reminders],
),
);
await _enqueueChange(
_buildEnvelope(
entityType: 'reminderRule',
entityId: reminder.id,
op: ChangeOperation.upsert,
payload: _reminderPayload(reminder),
),
);
}
Future<void> updateReminder(ReminderRule reminder) async {
if (reminder.title.trim().isEmpty) {
throw ArgumentError('Reminder title is required');
}
final LocalDataState current = _requireState();
final List<ReminderRule> reminders = current.reminders
.map((ReminderRule item) => item.id == reminder.id ? reminder : item)
.toList(growable: false);
await _setState(current.copyWith(reminders: reminders));
await _enqueueChange(
_buildEnvelope(
entityType: 'reminderRule',
entityId: reminder.id,
op: ChangeOperation.upsert,
payload: _reminderPayload(reminder),
),
);
}
Future<void> toggleReminderEnabled(String reminderId) async {
final LocalDataState current = _requireState();
ReminderRule? updatedReminder;
final List<ReminderRule> reminders = current.reminders
.map((ReminderRule item) {
if (item.id != reminderId) {
return item;
}
final ReminderRule toggled = item.copyWith(enabled: !item.enabled);
updatedReminder = toggled;
return toggled;
})
.toList(growable: false);
await _setState(current.copyWith(reminders: reminders));
if (updatedReminder != null) {
await _enqueueChange(
_buildEnvelope(
entityType: 'reminderRule',
entityId: updatedReminder!.id,
op: ChangeOperation.upsert,
payload: _reminderPayload(updatedReminder!),
),
);
}
}
Future<void> deleteReminder(String reminderId) async {
final LocalDataState current = _requireState();
final List<ReminderRule> reminders = current.reminders
.where((ReminderRule reminder) => reminder.id != reminderId)
.toList(growable: false);
await _setState(current.copyWith(reminders: reminders));
await _enqueueChange(
_buildEnvelope(
entityType: 'reminderRule',
entityId: reminderId,
op: ChangeOperation.delete,
),
);
}
Future<PersonPreferenceSignal> upsertInferredPreferenceSignalObservation({
required String personId,
required String key,
required String label,
required String category,
required PreferenceSignalPolarity polarity,
required double confidence,
String? sourceApp,
String? evidenceMessageId,
String? evidenceSnippet,
DateTime? observedAt,
}) async {
final LocalDataState current = _requireState();
final DateTime observed = observedAt ?? DateTime.now();
final String normalizedKey = _normalizePreferenceKey(key);
if (normalizedKey.isEmpty) {
throw ArgumentError('Preference signal key cannot be empty');
}
if (!_personExists(current.people, personId)) {
throw ArgumentError('Profile does not exist for preference signal');
}
final int existingIndex = current.preferenceSignals.indexWhere(
(PersonPreferenceSignal signal) =>
signal.personId == personId &&
signal.key == normalizedKey &&
signal.polarity == polarity,
);
final double normalizedConfidence = _clampUnit(confidence);
final String normalizedLabel = label.trim().isEmpty
? normalizedKey
: label.trim();
final String normalizedCategory = category.trim().isEmpty
? 'general'
: category.trim().toLowerCase();
final String? snippet = _trimToNull(evidenceSnippet);
final String? messageId = _trimToNull(evidenceMessageId);
final String? source = _trimToNull(sourceApp)?.toLowerCase();
late final PersonPreferenceSignal nextSignal;
final List<PersonPreferenceSignal> nextSignals = current.preferenceSignals
.toList(growable: true);
if (existingIndex < 0) {
nextSignal = PersonPreferenceSignal(
id: 'ps-${_uuid.v4()}',
personId: personId,
key: normalizedKey,
label: normalizedLabel,
category: normalizedCategory,
polarity: polarity,
confidence: normalizedConfidence,
status: PreferenceSignalStatus.inferred,
firstSeenAt: observed,
lastSeenAt: observed,
occurrenceCount: 1,
sourceApps: source == null ? const <String>[] : <String>[source],
evidenceMessageIds: messageId == null
? const <String>[]
: <String>[messageId],
evidenceSnippets: snippet == null
? const <String>[]
: <String>[snippet],
);
nextSignals.insert(0, nextSignal);
} else {
final PersonPreferenceSignal existing = nextSignals[existingIndex];
final int currentCount = existing.occurrenceCount < 1
? 1
: existing.occurrenceCount;
final int nextCount = currentCount + 1;
final double mergedConfidence =
((existing.confidence * currentCount) + normalizedConfidence) /
nextCount;
nextSignal = existing.copyWith(
label: normalizedLabel,
category: normalizedCategory,
confidence: _clampUnit(mergedConfidence),
lastSeenAt: observed,
occurrenceCount: nextCount,
sourceApps: _mergeUniqueStrings(existing.sourceApps, source),
evidenceMessageIds: _mergeUniqueStrings(
existing.evidenceMessageIds,
messageId,
maxItems: 8,
),
evidenceSnippets: _mergeUniqueStrings(
existing.evidenceSnippets,
snippet == null ? null : _truncate(snippet, 220),
maxItems: 6,
),
);
nextSignals[existingIndex] = nextSignal;
}
await _setState(current.copyWith(preferenceSignals: nextSignals));
return nextSignal;
}
Future<void> upsertPreferenceSignal(PersonPreferenceSignal signal) async {
final LocalDataState current = _requireState();
if (!_personExists(current.people, signal.personId)) {
throw ArgumentError('Profile does not exist for preference signal');
}
final List<PersonPreferenceSignal> nextSignals = _upsertItem(
items: current.preferenceSignals,
item: signal,
idOf: (PersonPreferenceSignal value) => value.id,
);
await _setState(current.copyWith(preferenceSignals: nextSignals));
}
Future<void> setPreferenceSignalStatus({
required String signalId,
required PreferenceSignalStatus status,
}) async {
final LocalDataState current = _requireState();
final List<PersonPreferenceSignal> nextSignals = current.preferenceSignals
.map((PersonPreferenceSignal signal) {
if (signal.id != signalId) {
return signal;
}
return signal.copyWith(status: status);
})
.toList(growable: false);
await _setState(current.copyWith(preferenceSignals: nextSignals));
}
Future<void> confirmPreferenceSignal(String signalId) {
return setPreferenceSignalStatus(
signalId: signalId,
status: PreferenceSignalStatus.confirmed,
);
}
Future<void> dismissPreferenceSignal(String signalId) {
return setPreferenceSignalStatus(
signalId: signalId,
status: PreferenceSignalStatus.dismissed,
);
}
Future<void> dismissLocalSignal(String signalId) async {
final String normalizedId = signalId.trim();
if (normalizedId.isEmpty) {
return;
}
final LocalDataState current = _requireState();
if (current.dismissedSignalIds.contains(normalizedId)) {
return;
}
await _setState(
current.copyWith(
dismissedSignalIds: <String>[
normalizedId,
...current.dismissedSignalIds,
],
),
);
}
Future<void> toggleTaskDone(String taskId) async {
final LocalDataState current = _requireState();
final List<DashboardTask> tasks = current.tasks
.map(
(DashboardTask task) =>
task.id == taskId ? task.copyWith(done: !task.done) : task,
)
.toList(growable: false);
await _setState(current.copyWith(tasks: tasks));
}
}
bool _personExists(List<PersonProfile> people, String personId) {
return people.any((PersonProfile person) => person.id == personId);
}
String _normalizePreferenceKey(String value) {
return value
.trim()
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9:_ -]+'), '')
.replaceAll(RegExp(r'\s+'), ' ');
}
double _clampUnit(double value) {
if (value.isNaN) {
return 0;
}
return value.clamp(0.0, 1.0).toDouble();
}
List<String> _mergeUniqueStrings(
List<String> existing,
String? incoming, {
int maxItems = 12,
}) {
final List<String> values = <String>[];
final Set<String> seen = <String>{};
for (final String raw in existing) {
final String value = raw.trim();
if (value.isEmpty) {
continue;
}
final String key = value.toLowerCase();
if (seen.add(key)) {
values.add(value);
}
}
final String? newValue = _trimToNull(incoming);
if (newValue != null) {
final String key = newValue.toLowerCase();
if (!seen.contains(key)) {
values.insert(0, newValue);
}
}
if (values.length <= maxItems) {
return values;
}
return values.take(maxItems).toList(growable: false);
}
String _truncate(String value, int maxChars) {
if (value.length <= maxChars) {
return value;
}
return value.substring(0, maxChars);
}
String _titleFromSummary(String summary) {
final List<String> words = summary.split(RegExp(r'\s+'));
final int take = words.length < 5 ? words.length : 5;
return words.take(take).join(' ');
}
@@ -0,0 +1,173 @@
part of 'relationship_repository.dart';
extension LocalRepositoryAiDigestOperations on LocalRepository {
Future<void> saveAiSuggestionDrafts(List<AiSuggestionDraft> drafts) async {
if (drafts.isEmpty) {
return;
}
final LocalDataState current = _requireState();
final Set<String> existingKeys = current.aiSuggestionDrafts
.map(_aiDraftDedupeKey)
.toSet();
final List<AiSuggestionDraft> uniqueDrafts = drafts
.where((AiSuggestionDraft draft) {
if (!_personExists(current.people, draft.personId)) {
return false;
}
return existingKeys.add(_aiDraftDedupeKey(draft));
})
.toList(growable: false);
if (uniqueDrafts.isEmpty) {
return;
}
await _setState(
current.copyWith(
aiSuggestionDrafts: <AiSuggestionDraft>[
...uniqueDrafts,
...current.aiSuggestionDrafts,
],
),
);
}
Future<void> acceptAiSuggestionDraft(String draftId) async {
final LocalDataState current = _requireState();
final AiSuggestionDraft? draft = current.aiSuggestionDrafts
.where((AiSuggestionDraft item) => item.id == draftId)
.cast<AiSuggestionDraft?>()
.firstOrNull;
if (draft == null || draft.status != AiSuggestionStatus.pending) {
return;
}
if (!_personExists(current.people, draft.personId)) {
await _setAiSuggestionStatus(draftId, AiSuggestionStatus.dismissed);
return;
}
final DateTime now = DateTime.now();
final List<AiSuggestionDraft> drafts = current.aiSuggestionDrafts
.map(
(AiSuggestionDraft item) => item.id == draftId
? item.copyWith(status: AiSuggestionStatus.accepted)
: item,
)
.toList(growable: false);
switch (draft.kind) {
case AiSuggestionKind.giftIdea:
case AiSuggestionKind.eventIdea:
final RelationshipIdea idea = RelationshipIdea(
id: 'i-${_uuid.v4()}',
personId: draft.personId,
type: draft.kind == AiSuggestionKind.giftIdea
? IdeaType.gift
: IdeaType.event,
title: draft.title,
details: _aiIdeaDetails(draft),
createdAt: now,
);
await _setState(
current.copyWith(
aiSuggestionDrafts: drafts,
ideas: <RelationshipIdea>[idea, ...current.ideas],
),
);
await _enqueueChange(
_buildEnvelope(
entityType: _ideaEntityType(idea.type),
entityId: idea.id,
op: ChangeOperation.upsert,
payload: _ideaPayload(idea),
),
);
case AiSuggestionKind.checkIn:
final DashboardTask task = DashboardTask(
id: 't-${_uuid.v4()}',
title: draft.title,
description: _aiDraftDetails(draft),
when: draft.suggestedFor ?? now.add(const Duration(days: 1)),
);
await _setState(
current.copyWith(
aiSuggestionDrafts: drafts,
tasks: <DashboardTask>[task, ...current.tasks],
),
);
case AiSuggestionKind.reminder:
final ReminderRule reminder = ReminderRule(
id: 'r-${_uuid.v4()}',
personId: draft.personId,
title: draft.title,
cadence: ReminderCadence.weekly,
nextAt: draft.suggestedFor ?? now.add(const Duration(days: 7)),
);
await _setState(
current.copyWith(
aiSuggestionDrafts: drafts,
reminders: <ReminderRule>[reminder, ...current.reminders],
),
);
await _enqueueChange(
_buildEnvelope(
entityType: 'reminderRule',
entityId: reminder.id,
op: ChangeOperation.upsert,
payload: _reminderPayload(reminder),
),
);
}
}
Future<void> dismissAiSuggestionDraft(String draftId) {
return _setAiSuggestionStatus(draftId, AiSuggestionStatus.dismissed);
}
Future<void> _setAiSuggestionStatus(
String draftId,
AiSuggestionStatus status,
) async {
final LocalDataState current = _requireState();
final List<AiSuggestionDraft> drafts = current.aiSuggestionDrafts
.map(
(AiSuggestionDraft item) =>
item.id == draftId ? item.copyWith(status: status) : item,
)
.toList(growable: false);
await _setState(current.copyWith(aiSuggestionDrafts: drafts));
}
}
String _aiDraftDedupeKey(AiSuggestionDraft draft) {
return <String>[
draft.personId,
draft.kind.name,
draft.title.trim().toLowerCase(),
draft.details.trim().toLowerCase(),
].join('|');
}
String _aiDraftDetails(AiSuggestionDraft draft) {
final String reason = draft.reason == null || draft.reason!.trim().isEmpty
? ''
: '\n\nWhy: ${draft.reason!.trim()}';
return '${draft.details.trim()}$reason'.trim();
}
String _aiIdeaDetails(AiSuggestionDraft draft) {
final List<String> parts = <String>[
_aiDraftDetails(draft),
if (draft.eventLocation != null && draft.eventLocation!.trim().isNotEmpty)
'Location: ${draft.eventLocation!.trim()}',
if (draft.eventUrl != null && draft.eventUrl!.trim().isNotEmpty)
'Event: ${draft.eventUrl!.trim()}',
if (draft.sourceUrls.isNotEmpty) 'Sources: ${draft.sourceUrls.join(', ')}',
].where((String value) => value.trim().isNotEmpty).toList(growable: false);
return parts.join('\n\n').trim();
}
extension<T> on Iterable<T> {
T? get firstOrNull => isEmpty ? null : first;
}
@@ -0,0 +1,523 @@
part of 'relationship_repository.dart';
/// Person-profile persistence and merge operations.
extension LocalRepositoryPeopleOperations on LocalRepository {
Future<void> addPerson({
required String name,
required String relationship,
required String notes,
required List<String> tags,
DateTime? nextMoment,
String? location,
List<String> aliases = const <String>[],
}) async {
final String normalizedName = name.trim();
final String normalizedRelationship = relationship.trim();
if (normalizedName.isEmpty || normalizedRelationship.isEmpty) {
throw ArgumentError('Name and relationship are required');
}
final DateTime now = DateTime.now();
final LocalDataState current = _requireState();
final PersonProfile person = PersonProfile(
id: 'p-${_uuid.v4()}',
name: normalizedName,
relationship: normalizedRelationship,
affinityScore: 75,
nextMoment: nextMoment ?? now.add(const Duration(days: 3)),
tags: _mergeTags(tags, const <String>[]),
notes: notes.trim(),
aliases: _normalizeAliases(aliases),
location: _trimToNull(location),
lastUpdatedAt: now,
);
await _setState(
current.copyWith(people: <PersonProfile>[person, ...current.people]),
);
await _enqueueChange(
_buildEnvelope(
entityType: 'person',
entityId: person.id,
op: ChangeOperation.upsert,
payload: _personPayload(person),
),
);
}
Future<void> updatePerson(PersonProfile person) async {
final String normalizedName = person.name.trim();
final String normalizedRelationship = person.relationship.trim();
if (normalizedName.isEmpty || normalizedRelationship.isEmpty) {
throw ArgumentError('Name and relationship are required');
}
final DateTime now = DateTime.now();
final PersonProfile updatedPerson = person.copyWith(
name: normalizedName,
relationship: normalizedRelationship,
notes: person.notes.trim(),
tags: _mergeTags(person.tags, const <String>[]),
aliases: _normalizeAliases(person.aliases),
location: _trimToNull(person.location),
lastUpdatedAt: now,
);
final LocalDataState current = _requireState();
final List<PersonProfile> updated = current.people
.map(
(PersonProfile item) =>
item.id == updatedPerson.id ? updatedPerson : item,
)
.toList(growable: false);
await _setState(current.copyWith(people: updated));
await _enqueueChange(
_buildEnvelope(
entityType: 'person',
entityId: updatedPerson.id,
op: ChangeOperation.upsert,
payload: _personPayload(updatedPerson),
),
);
}
Future<void> deletePerson(String personId) async {
final LocalDataState current = _requireState();
final List<RelationshipMoment> removedMoments = current.moments
.where((RelationshipMoment moment) => moment.personId == personId)
.toList(growable: false);
final List<RelationshipIdea> removedIdeas = current.ideas
.where((RelationshipIdea idea) => idea.personId == personId)
.toList(growable: false);
final List<ReminderRule> removedReminders = current.reminders
.where((ReminderRule reminder) => reminder.personId == personId)
.toList(growable: false);
final List<PersonFact> removedFacts = current.personFacts
.where((PersonFact fact) => fact.personId == personId)
.toList(growable: false);
final List<PersonImportantDate> removedDates = current.importantDates
.where((PersonImportantDate value) => value.personId == personId)
.toList(growable: false);
await _setState(
current.copyWith(
people: current.people
.where((PersonProfile person) => person.id != personId)
.toList(growable: false),
moments: current.moments
.where((RelationshipMoment moment) => moment.personId != personId)
.toList(growable: false),
ideas: current.ideas
.where((RelationshipIdea idea) => idea.personId != personId)
.toList(growable: false),
reminders: current.reminders
.where((ReminderRule reminder) => reminder.personId != personId)
.toList(growable: false),
preferenceSignals: current.preferenceSignals
.where(
(PersonPreferenceSignal signal) => signal.personId != personId,
)
.toList(growable: false),
personFacts: current.personFacts
.where((PersonFact fact) => fact.personId != personId)
.toList(growable: false),
aiSuggestionDrafts: current.aiSuggestionDrafts
.where((AiSuggestionDraft draft) => draft.personId != personId)
.toList(growable: false),
importantDates: current.importantDates
.where((PersonImportantDate value) => value.personId != personId)
.toList(growable: false),
sourceLinks: current.sourceLinks
.where((SourceProfileLink link) => link.profileId != personId)
.toList(growable: false),
sharedMessages: current.sharedMessages
.where((SharedMessageEntry entry) => entry.profileId != personId)
.toList(growable: false),
sharedInbox: current.sharedInbox
.map((SharedInboxEntry entry) {
final List<String> nextCandidates = entry.candidateProfileIds
.where((String candidateId) => candidateId != personId)
.toList(growable: false);
return entry.copyWith(
candidateProfileIds: nextCandidates,
targetPersonId: entry.targetPersonId == personId
? null
: entry.targetPersonId,
);
})
.toList(growable: false),
),
);
await _enqueueChanges(<ChangeEnvelope>[
_buildEnvelope(
entityType: 'person',
entityId: personId,
op: ChangeOperation.delete,
),
...removedMoments.map(
(RelationshipMoment moment) => _buildEnvelope(
entityType: 'capture',
entityId: moment.id,
op: ChangeOperation.delete,
),
),
...removedIdeas.map(
(RelationshipIdea idea) => _buildEnvelope(
entityType: _ideaEntityType(idea.type),
entityId: idea.id,
op: ChangeOperation.delete,
),
),
...removedReminders.map(
(ReminderRule reminder) => _buildEnvelope(
entityType: 'reminderRule',
entityId: reminder.id,
op: ChangeOperation.delete,
),
),
...removedFacts.map(
(PersonFact fact) => _buildEnvelope(
entityType: 'personFact',
entityId: fact.id,
op: ChangeOperation.delete,
),
),
...removedDates.map(
(PersonImportantDate value) => _buildEnvelope(
entityType: 'personDate',
entityId: value.id,
op: ChangeOperation.delete,
),
),
]);
}
Future<void> mergePersonProfiles({
required String sourcePersonId,
required String targetPersonId,
}) async {
if (sourcePersonId == targetPersonId) {
throw ArgumentError('Source and target profile must be different');
}
final LocalDataState current = _requireState();
final PersonProfile? source = _findPersonById(
current.people,
sourcePersonId,
);
final PersonProfile? target = _findPersonById(
current.people,
targetPersonId,
);
if (source == null || target == null) {
throw ArgumentError('Both source and target profiles must exist');
}
final DateTime now = DateTime.now();
final PersonProfile mergedTarget = target.copyWith(
affinityScore: target.affinityScore > source.affinityScore
? target.affinityScore
: source.affinityScore,
nextMoment: source.nextMoment.isBefore(target.nextMoment)
? source.nextMoment
: target.nextMoment,
tags: _mergeTags(target.tags, source.tags),
notes: _mergeNotes(
targetNotes: target.notes,
sourceName: source.name,
sourceNotes: source.notes,
),
aliases: _normalizeAliases(<String>[
...target.aliases,
...source.aliases,
]),
location: _effectiveLocation(target.location, source.location),
lastUpdatedAt: now,
lastInteractedAt: _latestDate(
target.lastInteractedAt,
source.lastInteractedAt,
),
);
final List<PersonProfile> nextPeople = current.people
.where((PersonProfile person) => person.id != sourcePersonId)
.map(
(PersonProfile person) =>
person.id == targetPersonId ? mergedTarget : person,
)
.toList(growable: false);
final List<RelationshipMoment> updatedMoments = <RelationshipMoment>[];
final List<RelationshipMoment> nextMoments = current.moments
.map((RelationshipMoment moment) {
if (moment.personId != sourcePersonId) {
return moment;
}
final RelationshipMoment merged = moment.copyWith(
personId: targetPersonId,
);
updatedMoments.add(merged);
return merged;
})
.toList(growable: false);
final List<RelationshipIdea> updatedIdeas = <RelationshipIdea>[];
final List<RelationshipIdea> nextIdeas = current.ideas
.map((RelationshipIdea idea) {
if (idea.personId != sourcePersonId) {
return idea;
}
final RelationshipIdea merged = idea.copyWith(
personId: targetPersonId,
);
updatedIdeas.add(merged);
return merged;
})
.toList(growable: false);
final List<ReminderRule> updatedReminders = <ReminderRule>[];
final List<ReminderRule> nextReminders = current.reminders
.map((ReminderRule reminder) {
if (reminder.personId != sourcePersonId) {
return reminder;
}
final ReminderRule merged = reminder.copyWith(
personId: targetPersonId,
);
updatedReminders.add(merged);
return merged;
})
.toList(growable: false);
final List<SourceProfileLink> nextLinks = current.sourceLinks
.map((SourceProfileLink link) {
if (link.profileId != sourcePersonId) {
return link;
}
return link.copyWith(profileId: targetPersonId);
})
.toList(growable: false);
final List<SharedMessageEntry> nextMessages = current.sharedMessages
.map((SharedMessageEntry entry) {
if (entry.profileId != sourcePersonId) {
return entry;
}
return entry.copyWith(profileId: targetPersonId);
})
.toList(growable: false);
final List<SharedInboxEntry> nextInbox = current.sharedInbox
.map((SharedInboxEntry entry) {
if (!entry.candidateProfileIds.contains(sourcePersonId) &&
entry.targetPersonId != sourcePersonId) {
return entry;
}
final List<String> candidates = entry.candidateProfileIds
.map(
(String profileId) =>
profileId == sourcePersonId ? targetPersonId : profileId,
)
.toSet()
.toList(growable: false);
return entry.copyWith(
candidateProfileIds: candidates,
targetPersonId: entry.targetPersonId == sourcePersonId
? targetPersonId
: entry.targetPersonId,
);
})
.toList(growable: false);
final List<PersonPreferenceSignal> nextPreferenceSignals = current
.preferenceSignals
.map((PersonPreferenceSignal signal) {
if (signal.personId != sourcePersonId) {
return signal;
}
return signal.copyWith(personId: targetPersonId);
})
.toList(growable: false);
final List<PersonFact> nextFacts = current.personFacts
.map((PersonFact fact) {
if (fact.personId != sourcePersonId) {
return fact;
}
return fact.copyWith(personId: targetPersonId, updatedAt: now);
})
.toList(growable: false);
final List<PersonImportantDate> nextImportantDates = current.importantDates
.map((PersonImportantDate value) {
if (value.personId != sourcePersonId) {
return value;
}
return value.copyWith(personId: targetPersonId, updatedAt: now);
})
.toList(growable: false);
final List<AiSuggestionDraft> nextAiSuggestionDrafts = current
.aiSuggestionDrafts
.map((AiSuggestionDraft draft) {
if (draft.personId != sourcePersonId) {
return draft;
}
return draft.copyWith(personId: targetPersonId);
})
.toList(growable: false);
await _setState(
current.copyWith(
people: nextPeople,
moments: nextMoments,
ideas: nextIdeas,
reminders: nextReminders,
sourceLinks: nextLinks,
sharedMessages: nextMessages,
sharedInbox: nextInbox,
personFacts: nextFacts,
importantDates: nextImportantDates,
preferenceSignals: nextPreferenceSignals,
aiSuggestionDrafts: nextAiSuggestionDrafts,
),
);
await _enqueueChanges(<ChangeEnvelope>[
_buildEnvelope(
entityType: 'person',
entityId: mergedTarget.id,
op: ChangeOperation.upsert,
payload: _personPayload(mergedTarget),
),
_buildEnvelope(
entityType: 'person',
entityId: sourcePersonId,
op: ChangeOperation.delete,
),
...updatedMoments.map(
(RelationshipMoment moment) => _buildEnvelope(
entityType: 'capture',
entityId: moment.id,
op: ChangeOperation.upsert,
payload: _momentPayload(moment),
),
),
...updatedIdeas.map(
(RelationshipIdea idea) => _buildEnvelope(
entityType: _ideaEntityType(idea.type),
entityId: idea.id,
op: ChangeOperation.upsert,
payload: _ideaPayload(idea),
),
),
...updatedReminders.map(
(ReminderRule reminder) => _buildEnvelope(
entityType: 'reminderRule',
entityId: reminder.id,
op: ChangeOperation.upsert,
payload: _reminderPayload(reminder),
),
),
]);
}
}
List<String> _mergeTags(List<String> primary, List<String> secondary) {
final List<String> merged = <String>[];
final Set<String> seen = <String>{};
for (final String raw in <String>[...primary, ...secondary]) {
final String tag = raw.trim();
if (tag.isEmpty) {
continue;
}
final String normalized = tag.toLowerCase();
if (seen.add(normalized)) {
merged.add(tag);
}
}
return merged;
}
String _mergeNotes({
required String targetNotes,
required String sourceName,
required String sourceNotes,
}) {
final String target = targetNotes.trim();
final String source = sourceNotes.trim();
if (target.isEmpty) {
return source;
}
if (source.isEmpty) {
return target;
}
if (target == source) {
return target;
}
return '$target\n\nMerged from $sourceName:\n$source';
}
String? _effectiveLocation(String? target, String? source) {
final String? targetValue = _trimToNull(target);
if (targetValue != null) {
return targetValue;
}
return _trimToNull(source);
}
List<String> _normalizeAliases(List<String> aliases) {
final List<String> values = <String>[];
final Set<String> seen = <String>{};
for (final String raw in aliases) {
final String value = raw.trim();
if (value.isEmpty) {
continue;
}
final String key = value.toLowerCase();
if (seen.add(key)) {
values.add(value);
}
}
return values;
}
DateTime? _latestDate(DateTime? left, DateTime? right) {
if (left == null) {
return right;
}
if (right == null) {
return left;
}
return left.isAfter(right) ? left : right;
}
List<PersonProfile> _touchPeople(
List<PersonProfile> people, {
required String personId,
DateTime? updatedAt,
DateTime? interactedAt,
}) {
return people
.map((PersonProfile person) {
if (person.id != personId) {
return person;
}
return person.copyWith(
lastUpdatedAt: updatedAt ?? person.lastUpdatedAt ?? DateTime.now(),
lastInteractedAt:
interactedAt ?? person.lastInteractedAt ?? DateTime.now(),
);
})
.toList(growable: false);
}
PersonProfile? _findPersonById(List<PersonProfile> people, String id) {
for (final PersonProfile person in people) {
if (person.id == id) {
return person;
}
}
return null;
}
@@ -0,0 +1,845 @@
part of 'relationship_repository.dart';
/// Share capture intake, person resolution, and inbox triage.
extension LocalRepositoryShareOperations on LocalRepository {
Future<SharedMessageIngestResult> ingestSharedMessage(
SharedMessageIngestInput input,
) async {
final DateTime now = DateTime.now();
final SharedPayload payload = SharedPayload(
sourceApp: input.sourceApp.trim().toLowerCase(),
payloadType: SharedPayloadType.text,
rawText: input.messageText.trim(),
createdAt: input.sharedAt ?? now,
receivedAt: now,
sharedMessageDateTime: input.sharedAt,
platform: 'legacy',
sourceDisplayName: _trimToNull(input.sourceDisplayName),
sourceUserId: _trimToNull(input.sourceUserId),
sourceThreadId: _trimToNull(input.sourceThreadId),
dedupeKey: _dedupeKeyForShare(
sourceApp: input.sourceApp,
rawText: input.messageText,
url: null,
sourceUserId: input.sourceUserId,
sourceThreadId: input.sourceThreadId,
),
);
if (payload.rawText.isEmpty) {
throw ArgumentError('Shared message text cannot be empty');
}
final LocalDataState current = _requireState();
final String sourceApp = payload.sourceApp;
final String? sourceDisplayName = payload.sourceDisplayName;
final String normalizedDisplayName = _normalizeDisplayName(
sourceDisplayName,
);
final String? sourceUserId = payload.sourceUserId;
final String? sourceThreadId = payload.sourceThreadId;
final String? sourceFingerprint = _buildStableSourceFingerprint(
sourceApp: sourceApp,
sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId,
);
final SourceProfileLink? matchedLink = _findExistingSourceLink(
links: current.sourceLinks,
sourceApp: sourceApp,
sourceFingerprint: sourceFingerprint,
sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId,
normalizedDisplayName: normalizedDisplayName,
);
PersonProfile? resolvedPerson;
if (matchedLink != null) {
resolvedPerson = _findPersonById(current.people, matchedLink.profileId);
}
final List<PersonProfile> matchingPeople = normalizedDisplayName.isEmpty
? const <PersonProfile>[]
: _findPeopleByNormalizedName(current.people, normalizedDisplayName);
if (resolvedPerson == null && matchingPeople.length == 1) {
resolvedPerson = matchingPeople.first;
}
if (resolvedPerson == null && matchingPeople.length > 1) {
return _queueSharedInbox(
current: current,
payload: payload,
sourceFingerprint: sourceFingerprint,
normalizedDisplayName: normalizedDisplayName,
reason: SharedInboxReason.ambiguousProfileMatch,
candidateProfileIds: matchingPeople
.map((PersonProfile person) => person.id)
.toList(growable: false),
);
}
final List<PersonProfile> nearMatchPeople = resolvedPerson == null
? _findNearMatchPeople(
current.people,
normalizedDisplayName: normalizedDisplayName,
)
: const <PersonProfile>[];
if (resolvedPerson == null && nearMatchPeople.isNotEmpty) {
return _queueSharedInbox(
current: current,
payload: payload,
sourceFingerprint: sourceFingerprint,
normalizedDisplayName: normalizedDisplayName,
reason: SharedInboxReason.nearProfileConflict,
candidateProfileIds: nearMatchPeople
.map((PersonProfile person) => person.id)
.toList(growable: false),
);
}
if (resolvedPerson == null) {
return _queueSharedInbox(
current: current,
payload: payload,
sourceFingerprint: sourceFingerprint,
normalizedDisplayName: normalizedDisplayName,
reason: SharedInboxReason.missingIdentity,
candidateProfileIds: const <String>[],
);
}
return _ingestIntoResolvedProfile(
current: current,
importedAt: now,
payload: payload,
sourceFingerprint: sourceFingerprint,
normalizedDisplayName: normalizedDisplayName,
resolvedPerson: resolvedPerson,
matchedLink: matchedLink,
createdProfile: false,
people: current.people,
resolvedAutomatically: true,
draft: _defaultDraftForPayload(payload),
);
}
Future<SharedMessageIngestResult> resolveSharedInboxToExistingProfile({
required String inboxEntryId,
required String profileId,
CapturedFactDraft? draft,
}) async {
final LocalDataState current = _requireState();
final SharedInboxEntry entry = _requireSharedInboxEntry(
current,
inboxEntryId,
);
final PersonProfile? resolvedPerson = _findPersonById(
current.people,
profileId,
);
if (resolvedPerson == null) {
throw ArgumentError('Profile not found for inbox entry');
}
return captureSharedPayloadToExistingProfile(
payload: entry.payload,
profileId: profileId,
draft: draft ?? entry.draft,
consumedInboxEntryId: entry.id,
normalizedDisplayName: entry.normalizedDisplayName,
sourceFingerprint: entry.sourceFingerprint,
resolvedAutomatically: false,
);
}
Future<SharedMessageIngestResult> resolveSharedInboxByCreatingProfile({
required String inboxEntryId,
String? name,
String relationship = 'WhatsApp Contact',
String? location,
List<String> aliases = const <String>[],
CapturedFactDraft? draft,
}) async {
final LocalDataState current = _requireState();
final SharedInboxEntry entry = _requireSharedInboxEntry(
current,
inboxEntryId,
);
return captureSharedPayloadByCreatingProfile(
payload: entry.payload,
name: name,
relationship: relationship,
location: location,
aliases: aliases,
draft: draft ?? entry.draft,
consumedInboxEntryId: entry.id,
normalizedDisplayName: entry.normalizedDisplayName,
sourceFingerprint: entry.sourceFingerprint,
resolvedAutomatically: false,
);
}
Future<SharedMessageIngestResult> saveSharedPayloadToInbox({
required SharedPayload payload,
SharedInboxReason reason = SharedInboxReason.missingIdentity,
List<String> candidateProfileIds = const <String>[],
String? normalizedDisplayName,
String? targetPersonId,
String? sourceFingerprint,
CapturedFactDraft? draft,
}) async {
final LocalDataState current = _requireState();
return _queueSharedInbox(
current: current,
payload: payload,
sourceFingerprint:
sourceFingerprint ??
_buildStableSourceFingerprint(
sourceApp: payload.sourceApp,
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
),
normalizedDisplayName:
normalizedDisplayName ??
_normalizeDisplayName(payload.sourceDisplayName),
reason: reason,
candidateProfileIds: candidateProfileIds,
targetPersonId: targetPersonId,
draft: draft,
);
}
Future<SharedMessageIngestResult> captureSharedPayloadToExistingProfile({
required SharedPayload payload,
required String profileId,
CapturedFactDraft? draft,
bool resolvedAutomatically = false,
String? consumedInboxEntryId,
String? normalizedDisplayName,
String? sourceFingerprint,
}) async {
final LocalDataState current = _requireState();
final PersonProfile? resolvedPerson = _findPersonById(
current.people,
profileId,
);
if (resolvedPerson == null) {
throw ArgumentError('Profile not found');
}
final String effectiveNormalizedDisplayName =
normalizedDisplayName ??
_normalizeDisplayName(payload.sourceDisplayName);
final String? effectiveSourceFingerprint =
sourceFingerprint ??
_buildStableSourceFingerprint(
sourceApp: payload.sourceApp,
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
);
final SourceProfileLink? matchedLink = _findExistingSourceLink(
links: current.sourceLinks,
sourceApp: payload.sourceApp,
sourceFingerprint: effectiveSourceFingerprint,
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
normalizedDisplayName: effectiveNormalizedDisplayName,
);
return _ingestIntoResolvedProfile(
current: current,
importedAt: DateTime.now(),
payload: payload,
sourceFingerprint: effectiveSourceFingerprint,
normalizedDisplayName: effectiveNormalizedDisplayName,
resolvedPerson: resolvedPerson,
matchedLink: matchedLink,
createdProfile: false,
people: current.people,
resolvedAutomatically: resolvedAutomatically,
consumedInboxEntryId: consumedInboxEntryId,
draft: draft ?? _defaultDraftForPayload(payload),
);
}
Future<SharedMessageIngestResult> captureSharedPayloadByCreatingProfile({
required SharedPayload payload,
String? name,
String relationship = 'Contact',
String? location,
List<String> aliases = const <String>[],
CapturedFactDraft? draft,
bool resolvedAutomatically = false,
String? consumedInboxEntryId,
String? normalizedDisplayName,
String? sourceFingerprint,
}) async {
final LocalDataState current = _requireState();
final DateTime now = DateTime.now();
final String? profileName = _trimToNull(name);
if (profileName == null) {
throw ArgumentError('A person name is required to create from a share');
}
final String normalizedRelationship = relationship.trim().isEmpty
? 'Contact'
: relationship.trim();
final PersonProfile createdPerson = PersonProfile(
id: 'p-${_uuid.v4()}',
name: profileName,
relationship: normalizedRelationship,
affinityScore: 70,
nextMoment: now.add(const Duration(days: 2)),
tags: <String>[
if (payload.sourceApp.trim().isNotEmpty) payload.sourceApp.trim(),
],
notes: 'Created from shared capture.',
aliases: _normalizeAliases(<String>[
...aliases,
if (payload.sourceDisplayName != null) payload.sourceDisplayName!,
]),
location: _trimToNull(location),
lastUpdatedAt: now,
lastInteractedAt: now,
);
return _ingestIntoResolvedProfile(
current: current,
importedAt: now,
payload: payload,
sourceFingerprint:
sourceFingerprint ??
_buildStableSourceFingerprint(
sourceApp: payload.sourceApp,
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
),
normalizedDisplayName:
normalizedDisplayName ??
_normalizeDisplayName(payload.sourceDisplayName),
resolvedPerson: createdPerson,
matchedLink: null,
createdProfile: true,
people: <PersonProfile>[createdPerson, ...current.people],
resolvedAutomatically: resolvedAutomatically,
consumedInboxEntryId: consumedInboxEntryId,
draft: draft ?? _defaultDraftForPayload(payload),
);
}
Future<void> dismissSharedInboxEntry(String inboxEntryId) async {
final LocalDataState current = _requireState();
final List<SharedInboxEntry> nextInbox = current.sharedInbox
.where((SharedInboxEntry entry) => entry.id != inboxEntryId)
.toList(growable: false);
if (nextInbox.length == current.sharedInbox.length) {
return;
}
await _setState(current.copyWith(sharedInbox: nextInbox));
}
Future<SharedMessageIngestResult> _queueSharedInbox({
required LocalDataState current,
required SharedPayload payload,
required SharedInboxReason reason,
required List<String> candidateProfileIds,
required String normalizedDisplayName,
String? sourceFingerprint,
String? targetPersonId,
CapturedFactDraft? draft,
}) async {
final SharedPayload storedPayload = payload.copyWith(
rawText: _truncate(payload.rawText, 1800),
dedupeKey:
payload.dedupeKey ??
_dedupeKeyForShare(
sourceApp: payload.sourceApp,
rawText: payload.rawText,
url: payload.url,
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
),
);
final SharedInboxEntry entry = SharedInboxEntry(
id: 'si-${_uuid.v4()}',
payload: storedPayload,
reason: reason,
candidateProfileIds: candidateProfileIds,
normalizedDisplayName: normalizedDisplayName,
assignmentStatus: ShareAssignmentStatus.savedToInbox,
targetPersonId: targetPersonId,
sourceFingerprint: sourceFingerprint,
draft: draft ?? _defaultDraftForPayload(storedPayload),
);
await _setState(
current.copyWith(
sharedInbox: <SharedInboxEntry>[entry, ...current.sharedInbox],
),
);
return SharedMessageIngestResult.queuedForResolution(
inboxEntryId: entry.id,
);
}
Future<SharedMessageIngestResult> _ingestIntoResolvedProfile({
required LocalDataState current,
required SharedPayload payload,
required DateTime importedAt,
required String normalizedDisplayName,
required String? sourceFingerprint,
required PersonProfile resolvedPerson,
required bool createdProfile,
required List<PersonProfile> people,
required bool resolvedAutomatically,
required CapturedFactDraft draft,
SourceProfileLink? matchedLink,
String? consumedInboxEntryId,
}) async {
final String sourceApp = payload.sourceApp;
final DateTime sharedAt =
payload.sharedMessageDateTime ?? payload.createdAt;
final String? sourceUserId = payload.sourceUserId;
final String? sourceThreadId = payload.sourceThreadId;
final bool createdSourceLink = matchedLink == null;
final List<SourceProfileLink> nextLinks = current.sourceLinks.toList(
growable: true,
);
if (matchedLink == null) {
nextLinks.insert(
0,
SourceProfileLink(
id: 'sl-${_uuid.v4()}',
sourceApp: sourceApp,
sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId,
sourceFingerprint: sourceFingerprint,
normalizedDisplayName: normalizedDisplayName,
profileId: resolvedPerson.id,
firstSeenAt: sharedAt,
lastSeenAt: sharedAt,
),
);
} else {
final int index = nextLinks.indexWhere(
(SourceProfileLink link) => link.id == matchedLink.id,
);
if (index >= 0) {
nextLinks[index] = matchedLink.copyWith(
sourceUserId: sourceUserId ?? matchedLink.sourceUserId,
sourceThreadId: sourceThreadId ?? matchedLink.sourceThreadId,
sourceFingerprint: sourceFingerprint ?? matchedLink.sourceFingerprint,
normalizedDisplayName: normalizedDisplayName.isEmpty
? matchedLink.normalizedDisplayName
: normalizedDisplayName,
profileId: resolvedPerson.id,
lastSeenAt: sharedAt,
);
}
}
final String summary = _truncate(payload.rawText, 1800);
final RelationshipMoment moment = RelationshipMoment(
id: 'm-${_uuid.v4()}',
personId: resolvedPerson.id,
title: _titleFromSummary(summary),
summary: summary,
at: sharedAt,
type: sourceApp == 'whatsapp' ? 'whatsapp' : 'shared',
);
final SharedMessageEntry sharedEntry = SharedMessageEntry(
id: 'sm-${_uuid.v4()}',
profileId: resolvedPerson.id,
payload: payload.copyWith(
rawText: summary,
dedupeKey:
payload.dedupeKey ??
_dedupeKeyForShare(
sourceApp: payload.sourceApp,
rawText: payload.rawText,
url: payload.url,
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
),
),
importedAt: importedAt,
resolvedAutomatically: resolvedAutomatically,
assignmentStatus: ShareAssignmentStatus.assignedDirectly,
draft: draft,
);
final List<SharedInboxEntry> nextInbox = consumedInboxEntryId == null
? current.sharedInbox
: current.sharedInbox
.where(
(SharedInboxEntry entry) => entry.id != consumedInboxEntryId,
)
.toList(growable: false);
await _setState(
current.copyWith(
people: _touchPeople(
people,
personId: resolvedPerson.id,
updatedAt: importedAt,
interactedAt: sharedAt,
),
moments: <RelationshipMoment>[moment, ...current.moments],
sourceLinks: nextLinks,
sharedMessages: <SharedMessageEntry>[
sharedEntry,
...current.sharedMessages,
],
sharedInbox: nextInbox,
personFacts: _applyFactDraftToFacts(
current.personFacts,
personId: resolvedPerson.id,
sharedEntry: sharedEntry,
draft: draft,
consumedInboxEntryId: consumedInboxEntryId,
observedAt: sharedAt,
),
importantDates: _applyFactDraftToDates(
current.importantDates,
personId: resolvedPerson.id,
draft: draft,
sourceApp: payload.sourceApp,
consumedInboxEntryId: consumedInboxEntryId,
observedAt: sharedAt,
),
),
);
final List<ChangeEnvelope> outbound = <ChangeEnvelope>[
if (createdProfile)
_buildEnvelope(
entityType: 'person',
entityId: resolvedPerson.id,
op: ChangeOperation.upsert,
payload: _personPayload(resolvedPerson),
),
_buildEnvelope(
entityType: 'capture',
entityId: moment.id,
op: ChangeOperation.upsert,
payload: _momentPayload(moment),
),
];
await _enqueueChanges(outbound);
final List<ExtractedPreferenceSignalCandidate> extractedSignals =
_chatPreferenceExtractor.extract(summary);
for (final ExtractedPreferenceSignalCandidate candidate
in extractedSignals) {
await upsertInferredPreferenceSignalObservation(
personId: resolvedPerson.id,
key: candidate.key,
label: candidate.label,
category: candidate.category,
polarity: candidate.polarity,
confidence: candidate.confidence,
sourceApp: sourceApp,
evidenceMessageId: sharedEntry.id,
evidenceSnippet: candidate.evidenceSnippet,
observedAt: sharedAt,
);
}
return SharedMessageIngestResult.imported(
profileId: resolvedPerson.id,
profileName: resolvedPerson.name,
createdProfile: createdProfile,
createdSourceLink: createdSourceLink,
momentId: moment.id,
);
}
}
CapturedFactDraft _defaultDraftForPayload(SharedPayload payload) {
return ShareCaptureDraftSuggester.suggestForPayload(
payload,
).copyWith(needsReview: true);
}
String _dedupeKeyForShare({
required String sourceApp,
required String rawText,
required String? url,
required String? sourceUserId,
required String? sourceThreadId,
}) {
final String text = rawText.trim().toLowerCase().replaceAll(
RegExp(r'\s+'),
' ',
);
return <String>[
sourceApp.trim().toLowerCase(),
sourceUserId?.trim().toLowerCase() ?? '',
sourceThreadId?.trim().toLowerCase() ?? '',
url?.trim().toLowerCase() ?? '',
text,
].join('|');
}
List<PersonFact> _applyFactDraftToFacts(
List<PersonFact> currentFacts, {
required String personId,
required SharedMessageEntry sharedEntry,
required CapturedFactDraft draft,
required String? consumedInboxEntryId,
required DateTime observedAt,
}) {
if (draft.type == CapturedFactType.importantDate ||
draft.text.trim().isEmpty) {
return currentFacts;
}
final PersonFact fact = PersonFact(
id: 'pf-${_uuid.v4()}',
personId: personId,
type: draft.type,
text: draft.text.trim(),
label: _trimToNull(draft.label),
sourceKind: consumedInboxEntryId == null
? CaptureSourceKind.shareSheet
: CaptureSourceKind.shareInbox,
sourceApp: sharedEntry.sourceApp,
sourceUrl: sharedEntry.url,
sharedMessageId: sharedEntry.id,
inboxEntryId: consumedInboxEntryId,
confidence: draft.confidence,
needsReview: draft.needsReview,
isSensitive: draft.isSensitive,
createdAt: observedAt,
updatedAt: observedAt,
);
return <PersonFact>[fact, ...currentFacts];
}
List<PersonImportantDate> _applyFactDraftToDates(
List<PersonImportantDate> currentDates, {
required String personId,
required CapturedFactDraft draft,
required String sourceApp,
required String? consumedInboxEntryId,
required DateTime observedAt,
}) {
if (draft.type != CapturedFactType.importantDate || draft.dateValue == null) {
return currentDates;
}
final PersonImportantDate value = PersonImportantDate(
id: 'pd-${_uuid.v4()}',
personId: personId,
label: _trimToNull(draft.label) ?? draft.text.trim(),
date: draft.dateValue!,
classification: 'important',
sourceKind: consumedInboxEntryId == null
? CaptureSourceKind.shareSheet
: CaptureSourceKind.shareInbox,
sourceApp: sourceApp,
inboxEntryId: consumedInboxEntryId,
isSensitive: draft.isSensitive,
createdAt: observedAt,
updatedAt: observedAt,
);
return <PersonImportantDate>[value, ...currentDates];
}
SourceProfileLink? _findExistingSourceLink({
required List<SourceProfileLink> links,
required String sourceApp,
required String? sourceFingerprint,
required String? sourceUserId,
required String? sourceThreadId,
required String normalizedDisplayName,
}) {
if (sourceFingerprint != null && sourceFingerprint.isNotEmpty) {
for (final SourceProfileLink link in links) {
final String? linkFingerprint =
link.sourceFingerprint ??
_buildStableSourceFingerprint(
sourceApp: link.sourceApp,
sourceUserId: link.sourceUserId,
sourceThreadId: link.sourceThreadId,
);
if (link.sourceApp == sourceApp && linkFingerprint == sourceFingerprint) {
return link;
}
}
}
if (sourceUserId != null && sourceUserId.isNotEmpty) {
for (final SourceProfileLink link in links) {
if (link.sourceApp == sourceApp && link.sourceUserId == sourceUserId) {
return link;
}
}
}
if (sourceThreadId != null && sourceThreadId.isNotEmpty) {
for (final SourceProfileLink link in links) {
if (link.sourceApp == sourceApp &&
link.sourceThreadId == sourceThreadId) {
return link;
}
}
}
if (normalizedDisplayName.isNotEmpty) {
for (final SourceProfileLink link in links) {
if (link.sourceApp == sourceApp &&
link.normalizedDisplayName == normalizedDisplayName) {
return link;
}
}
}
return null;
}
List<PersonProfile> _findPeopleByNormalizedName(
List<PersonProfile> people,
String normalizedDisplayName,
) {
return people
.where((PersonProfile person) {
if (_normalizeDisplayName(person.name) == normalizedDisplayName) {
return true;
}
for (final String alias in person.aliases) {
if (_normalizeDisplayName(alias) == normalizedDisplayName) {
return true;
}
}
return false;
})
.toList(growable: false);
}
List<PersonProfile> _findNearMatchPeople(
List<PersonProfile> people, {
required String normalizedDisplayName,
}) {
if (normalizedDisplayName.isEmpty) {
return const <PersonProfile>[];
}
final Map<String, int> distanceById = <String, int>{};
final List<PersonProfile> candidates = <PersonProfile>[];
for (final PersonProfile person in people) {
final List<String> probes = <String>[person.name, ...person.aliases];
for (final String probe in probes) {
final String personName = _normalizeDisplayName(probe);
if (personName.isEmpty || personName == normalizedDisplayName) {
continue;
}
final int distance = _levenshteinDistance(
normalizedDisplayName,
personName,
);
if (_isNearDisplayName(
normalizedDisplayName,
personName,
distance: distance,
)) {
distanceById[person.id] = distance;
if (!candidates.any((PersonProfile item) => item.id == person.id)) {
candidates.add(person);
}
}
}
}
candidates.sort((PersonProfile a, PersonProfile b) {
final int left = distanceById[a.id] ?? 999;
final int right = distanceById[b.id] ?? 999;
if (left != right) {
return left.compareTo(right);
}
return a.name.length.compareTo(b.name.length);
});
if (candidates.length <= 5) {
return candidates;
}
return candidates.take(5).toList(growable: false);
}
bool _isNearDisplayName(String left, String right, {required int distance}) {
final int maxLength = left.length > right.length ? left.length : right.length;
final int minLength = left.length < right.length ? left.length : right.length;
if (minLength >= 4 && (left.contains(right) || right.contains(left))) {
return true;
}
final int threshold = maxLength >= 14 ? 3 : 2;
if (distance <= threshold) {
return true;
}
return maxLength >= 8 && (distance / maxLength) <= 0.22;
}
int _levenshteinDistance(String left, String right) {
if (left == right) {
return 0;
}
if (left.isEmpty) {
return right.length;
}
if (right.isEmpty) {
return left.length;
}
List<int> previous = List<int>.generate(
right.length + 1,
(int index) => index,
growable: false,
);
for (int i = 1; i <= left.length; i += 1) {
final List<int> current = List<int>.filled(right.length + 1, 0);
current[0] = i;
for (int j = 1; j <= right.length; j += 1) {
final int substitutionCost = left[i - 1] == right[j - 1] ? 0 : 1;
final int deletion = previous[j] + 1;
final int insertion = current[j - 1] + 1;
final int substitution = previous[j - 1] + substitutionCost;
final int value = deletion < insertion ? deletion : insertion;
current[j] = value < substitution ? value : substitution;
}
previous = current;
}
return previous[right.length];
}
SharedInboxEntry _requireSharedInboxEntry(
LocalDataState current,
String inboxEntryId,
) {
for (final SharedInboxEntry entry in current.sharedInbox) {
if (entry.id == inboxEntryId) {
return entry;
}
}
throw ArgumentError('Shared inbox entry not found');
}
String? _buildStableSourceFingerprint({
required String sourceApp,
required String? sourceUserId,
required String? sourceThreadId,
}) {
final String? userId = _trimToNull(sourceUserId);
final String? threadId = _trimToNull(sourceThreadId);
if (userId == null && threadId == null) {
return null;
}
final List<String> parts = <String>[
sourceApp.trim().toLowerCase(),
if (userId != null) 'u:${userId.toLowerCase()}',
if (threadId != null) 't:${threadId.toLowerCase()}',
];
return parts.join('|');
}
String _normalizeDisplayName(String? raw) {
if (raw == null) {
return '';
}
final String compact = raw
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9]+'), ' ')
.trim();
return compact.replaceAll(RegExp(r'\s+'), ' ');
}
@@ -0,0 +1,486 @@
part of 'relationship_repository.dart';
/// Sync queue integration and change-envelope serialization helpers.
extension LocalRepositorySyncOperations on LocalRepository {
Future<void> applyRemoteChanges(List<ChangeEnvelope> changes) async {
if (changes.isEmpty) {
return;
}
LocalDataState next = _requireState();
for (final ChangeEnvelope change in changes) {
next = _applyRemoteChange(next, change);
}
await _setState(next);
}
LocalDataState _applyRemoteChange(
LocalDataState current,
ChangeEnvelope change,
) {
switch (change.entityType) {
case 'person':
return _applyPersonChange(current, change);
case 'capture':
return _applyMomentChange(current, change);
case 'giftIdea':
case 'eventIdea':
return _applyIdeaChange(current, change);
case 'reminderRule':
return _applyReminderChange(current, change);
default:
return current;
}
}
LocalDataState _applyPersonChange(
LocalDataState current,
ChangeEnvelope change,
) {
if (change.op == ChangeOperation.delete) {
final String personId = change.entityId;
return current.copyWith(
people: current.people
.where((PersonProfile person) => person.id != personId)
.toList(growable: false),
moments: current.moments
.where((RelationshipMoment moment) => moment.personId != personId)
.toList(growable: false),
ideas: current.ideas
.where((RelationshipIdea idea) => idea.personId != personId)
.toList(growable: false),
reminders: current.reminders
.where((ReminderRule reminder) => reminder.personId != personId)
.toList(growable: false),
preferenceSignals: current.preferenceSignals
.where(
(PersonPreferenceSignal signal) => signal.personId != personId,
)
.toList(growable: false),
personFacts: current.personFacts
.where((PersonFact fact) => fact.personId != personId)
.toList(growable: false),
aiSuggestionDrafts: current.aiSuggestionDrafts
.where((AiSuggestionDraft draft) => draft.personId != personId)
.toList(growable: false),
importantDates: current.importantDates
.where((PersonImportantDate value) => value.personId != personId)
.toList(growable: false),
sourceLinks: current.sourceLinks
.where((SourceProfileLink link) => link.profileId != personId)
.toList(growable: false),
sharedMessages: current.sharedMessages
.where((SharedMessageEntry entry) => entry.profileId != personId)
.toList(growable: false),
);
}
final int existingIndex = current.people.indexWhere(
(PersonProfile person) => person.id == change.entityId,
);
final PersonProfile? existing = existingIndex >= 0
? current.people[existingIndex]
: null;
final Map<String, dynamic> payload = change.payload ?? <String, dynamic>{};
final PersonProfile next = PersonProfile(
id: change.entityId,
name: _stringField(payload, 'name', existing?.name ?? 'Unknown'),
relationship: _stringField(
payload,
'relationship',
existing?.relationship ?? 'Relationship',
),
affinityScore: _intField(
payload,
'affinityScore',
existing?.affinityScore ?? 70,
),
nextMoment: _dateField(
payload,
'nextMoment',
existing?.nextMoment ?? DateTime.now().add(const Duration(days: 3)),
),
tags: _stringListField(payload, 'tags', existing?.tags ?? <String>[]),
notes: _stringField(payload, 'notes', existing?.notes ?? ''),
aliases: _stringListField(
payload,
'aliases',
existing?.aliases ?? <String>[],
),
location: _stringOrNullField(payload, 'location', existing?.location),
lastUpdatedAt: _dateOrNullField(
payload,
'lastUpdatedAt',
existing?.lastUpdatedAt,
),
lastInteractedAt: _dateOrNullField(
payload,
'lastInteractedAt',
existing?.lastInteractedAt,
),
);
final List<PersonProfile> people = _upsertItem(
items: current.people,
item: next,
idOf: (PersonProfile person) => person.id,
);
return current.copyWith(people: people);
}
LocalDataState _applyMomentChange(
LocalDataState current,
ChangeEnvelope change,
) {
if (change.op == ChangeOperation.delete) {
return current.copyWith(
moments: current.moments
.where((RelationshipMoment moment) => moment.id != change.entityId)
.toList(growable: false),
);
}
final int existingIndex = current.moments.indexWhere(
(RelationshipMoment moment) => moment.id == change.entityId,
);
final RelationshipMoment? existing = existingIndex >= 0
? current.moments[existingIndex]
: null;
final Map<String, dynamic> payload = change.payload ?? <String, dynamic>{};
final String summary = _stringField(
payload,
'summary',
existing?.summary ?? '',
);
final RelationshipMoment next = RelationshipMoment(
id: change.entityId,
personId: _stringField(
payload,
'personId',
existing?.personId ??
(current.people.isEmpty
? 'person-unknown'
: current.people.first.id),
),
title: _stringField(
payload,
'title',
existing?.title ?? _titleFromSummary(summary),
),
summary: summary,
at: _dateField(payload, 'at', existing?.at ?? DateTime.now()),
type: _stringField(payload, 'type', existing?.type ?? 'capture'),
);
final List<RelationshipMoment> moments = _upsertItem(
items: current.moments,
item: next,
idOf: (RelationshipMoment moment) => moment.id,
);
return current.copyWith(moments: moments);
}
LocalDataState _applyIdeaChange(
LocalDataState current,
ChangeEnvelope change,
) {
if (change.op == ChangeOperation.delete) {
return current.copyWith(
ideas: current.ideas
.where((RelationshipIdea idea) => idea.id != change.entityId)
.toList(growable: false),
);
}
final int existingIndex = current.ideas.indexWhere(
(RelationshipIdea idea) => idea.id == change.entityId,
);
final RelationshipIdea? existing = existingIndex >= 0
? current.ideas[existingIndex]
: null;
final Map<String, dynamic> payload = change.payload ?? <String, dynamic>{};
final IdeaType fallbackType = _ideaTypeFromEntityType(
change.entityType,
fallback: existing?.type ?? IdeaType.gift,
);
final IdeaType type = _ideaTypeFromRaw(
payload['type'],
fallback: fallbackType,
);
final RelationshipIdea next = RelationshipIdea(
id: change.entityId,
personId: payload['personId'] as String? ?? existing?.personId,
type: type,
title: _stringField(payload, 'title', existing?.title ?? 'Untitled idea'),
details: _stringField(payload, 'details', existing?.details ?? ''),
createdAt: _dateField(
payload,
'createdAt',
existing?.createdAt ?? DateTime.now(),
),
isArchived: _boolField(
payload,
'isArchived',
existing?.isArchived ?? false,
),
);
final List<RelationshipIdea> ideas = _upsertItem(
items: current.ideas,
item: next,
idOf: (RelationshipIdea idea) => idea.id,
);
return current.copyWith(ideas: ideas);
}
LocalDataState _applyReminderChange(
LocalDataState current,
ChangeEnvelope change,
) {
if (change.op == ChangeOperation.delete) {
return current.copyWith(
reminders: current.reminders
.where((ReminderRule reminder) => reminder.id != change.entityId)
.toList(growable: false),
);
}
final int existingIndex = current.reminders.indexWhere(
(ReminderRule reminder) => reminder.id == change.entityId,
);
final ReminderRule? existing = existingIndex >= 0
? current.reminders[existingIndex]
: null;
final Map<String, dynamic> payload = change.payload ?? <String, dynamic>{};
final ReminderRule next = ReminderRule(
id: change.entityId,
personId: payload['personId'] as String? ?? existing?.personId,
title: _stringField(payload, 'title', existing?.title ?? 'Reminder'),
cadence: _cadenceFromRaw(
payload['cadence'],
fallback: existing?.cadence ?? ReminderCadence.weekly,
),
nextAt: _dateField(payload, 'nextAt', existing?.nextAt ?? DateTime.now()),
enabled: _boolField(payload, 'enabled', existing?.enabled ?? true),
);
final List<ReminderRule> reminders = _upsertItem(
items: current.reminders,
item: next,
idOf: (ReminderRule reminder) => reminder.id,
);
return current.copyWith(reminders: reminders);
}
ChangeEnvelope _buildEnvelope({
required String entityType,
required String entityId,
required ChangeOperation op,
Map<String, dynamic>? payload,
}) {
return ChangeEnvelope(
schemaVersion: _syncSchemaVersion,
entityType: entityType,
entityId: entityId,
op: op,
modifiedAt: DateTime.now().toUtc(),
clientMutationId: _uuid.v4(),
payload: payload,
);
}
Map<String, dynamic> _personPayload(PersonProfile person) {
return <String, dynamic>{
'name': person.name,
'relationship': person.relationship,
'affinityScore': person.affinityScore,
'nextMoment': person.nextMoment.toUtc().toIso8601String(),
'tags': person.tags,
'notes': person.notes,
'aliases': person.aliases,
'location': person.location,
'lastUpdatedAt': person.lastUpdatedAt?.toUtc().toIso8601String(),
'lastInteractedAt': person.lastInteractedAt?.toUtc().toIso8601String(),
};
}
Map<String, dynamic> _momentPayload(RelationshipMoment moment) {
return <String, dynamic>{
'personId': moment.personId,
'title': moment.title,
'summary': moment.summary,
'at': moment.at.toUtc().toIso8601String(),
'type': moment.type,
};
}
Map<String, dynamic> _ideaPayload(RelationshipIdea idea) {
return <String, dynamic>{
'personId': idea.personId,
'type': idea.type.name,
'title': idea.title,
'details': idea.details,
'createdAt': idea.createdAt.toUtc().toIso8601String(),
'isArchived': idea.isArchived,
};
}
Map<String, dynamic> _reminderPayload(ReminderRule reminder) {
return <String, dynamic>{
'personId': reminder.personId,
'title': reminder.title,
'cadence': reminder.cadence.name,
'nextAt': reminder.nextAt.toUtc().toIso8601String(),
'enabled': reminder.enabled,
};
}
}
String _ideaEntityType(IdeaType type) {
return type == IdeaType.event ? 'eventIdea' : 'giftIdea';
}
String _ideaEntityTypeFromId(List<RelationshipIdea> ideas, String ideaId) {
final int index = ideas.indexWhere(
(RelationshipIdea idea) => idea.id == ideaId,
);
final RelationshipIdea? existing = index >= 0 ? ideas[index] : null;
if (existing == null) {
return 'giftIdea';
}
return _ideaEntityType(existing.type);
}
IdeaType _ideaTypeFromEntityType(
String entityType, {
required IdeaType fallback,
}) {
switch (entityType) {
case 'eventIdea':
return IdeaType.event;
case 'giftIdea':
return IdeaType.gift;
default:
return fallback;
}
}
IdeaType _ideaTypeFromRaw(dynamic value, {required IdeaType fallback}) {
if (value is! String || value.isEmpty) {
return fallback;
}
return IdeaType.values.firstWhere(
(IdeaType type) => type.name == value,
orElse: () => fallback,
);
}
ReminderCadence _cadenceFromRaw(
dynamic value, {
required ReminderCadence fallback,
}) {
if (value is! String || value.isEmpty) {
return fallback;
}
return ReminderCadence.values.firstWhere(
(ReminderCadence cadence) => cadence.name == value,
orElse: () => fallback,
);
}
List<T> _upsertItem<T>({
required List<T> items,
required T item,
required String Function(T value) idOf,
}) {
final int index = items.indexWhere((T value) => idOf(value) == idOf(item));
if (index < 0) {
return <T>[item, ...items];
}
final List<T> copy = items.toList();
copy[index] = item;
return copy;
}
String _stringField(Map<String, dynamic> payload, String key, String fallback) {
final dynamic value = payload[key];
if (value is String && value.trim().isNotEmpty) {
return value.trim();
}
return fallback;
}
String? _stringOrNullField(
Map<String, dynamic> payload,
String key,
String? fallback,
) {
final dynamic value = payload[key];
if (value is String) {
final String trimmed = value.trim();
if (trimmed.isNotEmpty) {
return trimmed;
}
return null;
}
return fallback;
}
int _intField(Map<String, dynamic> payload, String key, int fallback) {
final dynamic value = payload[key];
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
return fallback;
}
bool _boolField(Map<String, dynamic> payload, String key, bool fallback) {
final dynamic value = payload[key];
if (value is bool) {
return value;
}
return fallback;
}
List<String> _stringListField(
Map<String, dynamic> payload,
String key,
List<String> fallback,
) {
final dynamic value = payload[key];
if (value is! List<dynamic>) {
return fallback;
}
return value.map((dynamic item) => '$item').toList(growable: false);
}
DateTime _dateField(
Map<String, dynamic> payload,
String key,
DateTime fallback,
) {
final dynamic value = payload[key];
if (value is String) {
final DateTime? parsed = DateTime.tryParse(value);
if (parsed != null) {
return parsed.toLocal();
}
}
return fallback;
}
DateTime? _dateOrNullField(
Map<String, dynamic> payload,
String key,
DateTime? fallback,
) {
final dynamic value = payload[key];
if (value is String) {
final DateTime? parsed = DateTime.tryParse(value);
return parsed?.toLocal();
}
return fallback;
}
+7
View File
@@ -0,0 +1,7 @@
# App Storage
This folder owns local persistence adapters for the aggregate app store.
Use these files when you need to change how the local snapshot is stored. The
contract is `local_data_store.dart`; Hive and shared-preferences adapters are
swap-in implementations behind the provider.
@@ -0,0 +1,19 @@
/// Raw local state payload and associated schema version.
class LocalDataRecord {
const LocalDataRecord({required this.rawState, required this.schemaVersion});
final String rawState;
final int schemaVersion;
}
/// Persistence boundary for local app state.
abstract interface class LocalDataStore {
/// Reads latest persisted state payload.
Future<LocalDataRecord?> read();
/// Writes state payload and schema version.
Future<void> write({required String rawState, required int schemaVersion});
/// Clears persisted state payload.
Future<void> clear();
}
@@ -0,0 +1,54 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'package:relationship_saver/app/data/storage/local_data_store.dart';
/// Hive-backed local store for future DB migration path.
class HiveLocalDataStore implements LocalDataStore {
static const String _boxName = 'relationship_saver_local';
static const String _stateKey = 'state_json';
static const String _schemaVersionKey = 'schema_version';
static bool _initialized = false;
@override
Future<void> clear() async {
final Box<dynamic> box = await _openBox();
await box.delete(_stateKey);
await box.delete(_schemaVersionKey);
}
@override
Future<LocalDataRecord?> read() async {
final Box<dynamic> box = await _openBox();
final String? rawState = box.get(_stateKey) as String?;
if (rawState == null || rawState.isEmpty) {
return null;
}
final int schemaVersion =
(box.get(_schemaVersionKey) as num?)?.toInt() ?? 0;
return LocalDataRecord(rawState: rawState, schemaVersion: schemaVersion);
}
@override
Future<void> write({
required String rawState,
required int schemaVersion,
}) async {
final Box<dynamic> box = await _openBox();
await box.put(_stateKey, rawState);
await box.put(_schemaVersionKey, schemaVersion);
}
Future<Box<dynamic>> _openBox() async {
if (!_initialized) {
await Hive.initFlutter();
_initialized = true;
}
if (!Hive.isBoxOpen(_boxName)) {
return Hive.openBox<dynamic>(_boxName);
}
return Hive.box<dynamic>(_boxName);
}
}
@@ -0,0 +1,22 @@
import 'package:relationship_saver/app/data/storage/local_data_store.dart';
/// In-memory local store for deterministic tests.
class InMemoryLocalDataStore implements LocalDataStore {
LocalDataRecord? _record;
@override
Future<void> clear() async {
_record = null;
}
@override
Future<LocalDataRecord?> read() async => _record;
@override
Future<void> write({
required String rawState,
required int schemaVersion,
}) async {
_record = LocalDataRecord(rawState: rawState, schemaVersion: schemaVersion);
}
}
@@ -0,0 +1,14 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/app/data/storage/local_data_store.dart';
import 'package:relationship_saver/app/data/storage/local_data_store_hive.dart';
import 'package:relationship_saver/app/data/storage/local_data_store_shared_prefs.dart';
import 'package:relationship_saver/core/config/app_config.dart';
/// Chooses local persistence backend for app state.
final Provider<LocalDataStore> localDataStoreProvider =
Provider<LocalDataStore>((Ref ref) {
if (AppConfig.useHiveLocalDb) {
return HiveLocalDataStore();
}
return SharedPrefsLocalDataStore();
});
@@ -0,0 +1,44 @@
import 'package:relationship_saver/app/data/storage/local_data_store.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// shared_preferences-backed local store used as stable default.
class SharedPrefsLocalDataStore implements LocalDataStore {
SharedPrefsLocalDataStore({
this.stateKey = 'local_data_state_v1',
this.schemaVersionKey = 'local_data_schema_version',
});
final String stateKey;
final String schemaVersionKey;
@override
Future<void> clear() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.remove(stateKey);
await prefs.remove(schemaVersionKey);
}
@override
Future<LocalDataRecord?> read() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
final String? rawState = prefs.getString(stateKey);
if (rawState == null || rawState.isEmpty) {
return null;
}
return LocalDataRecord(
rawState: rawState,
schemaVersion: prefs.getInt(schemaVersionKey) ?? 0,
);
}
@override
Future<void> write({
required String rawState,
required int schemaVersion,
}) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setString(stateKey, rawState);
await prefs.setInt(schemaVersionKey, schemaVersion);
}
}
+6
View File
@@ -0,0 +1,6 @@
# App Navigation
This folder contains typed shell destinations used by the main app shell.
Change `app_destination.dart` when you add, remove, or rename a top-level tab.
The shell in `../presentation/` maps these destinations to screens.
+14
View File
@@ -0,0 +1,14 @@
import 'package:flutter/material.dart';
/// Typed shell destinations used by the app navigation shell.
enum AppDestination {
shareInbox('Capture', Icons.inbox_rounded),
people('People', Icons.people_alt_rounded),
aiReview('Digest', Icons.rate_review_rounded),
settings('Settings', Icons.settings_rounded);
const AppDestination(this.label, this.icon);
final String label;
final IconData icon;
}
+7
View File
@@ -0,0 +1,7 @@
# App Presentation
This folder owns the shared app shell UI.
`app_shell.dart` is intentionally the only place that knows how wide-layout and
compact-layout navigation work. Feature screens should stay unaware of shell
details and just render their slice.
+282
View File
@@ -0,0 +1,282 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/app/navigation/app_destination.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/ai_digest/presentation/ai_digest_review_view.dart';
import 'package:relationship_saver/features/people/presentation/people_view.dart';
import 'package:relationship_saver/features/settings/presentation/settings_view.dart';
import 'package:relationship_saver/features/share_intake/presentation/share_inbox_view.dart';
/// Responsive shell that hosts the main feature slices.
class AppShell extends ConsumerStatefulWidget {
const AppShell({super.key});
@override
ConsumerState<AppShell> createState() => _AppShellState();
}
class _AppShellState extends ConsumerState<AppShell> {
AppDestination _destination = AppDestination.shareInbox;
static const List<AppDestination> _destinations = AppDestination.values;
static const List<AppDestination> _compactPrimaryDestinations =
AppDestination.values;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: <Color>[
Color(0xFFF3F8FB),
Color(0xFFEAF1F8),
Color(0xFFF8FAFC),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
if (constraints.maxWidth < 1000) {
return _buildCompactLayout(context);
}
return Row(
children: <Widget>[
_Sidebar(
selected: _destination,
destinations: _destinations,
onChange: _selectDestination,
),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
layoutBuilder: _expandedSwitcherLayout,
child: KeyedSubtree(
key: ValueKey<AppDestination>(_destination),
child: _screenFor(_destination),
),
),
),
],
);
},
),
),
),
);
}
Widget _buildCompactLayout(BuildContext context) {
final int compactIndex = _compactSelectedIndex(_destination);
final AppDestination active = _compactPrimaryDestinations[compactIndex];
return _CompactShell(
selected: active,
body: _screenFor(active),
destinations: _compactPrimaryDestinations,
onChange: _selectDestination,
);
}
void _selectDestination(AppDestination destination) {
setState(() {
_destination = destination;
});
}
int _compactSelectedIndex(AppDestination destination) {
for (int i = 0; i < _compactPrimaryDestinations.length; i += 1) {
if (_compactPrimaryDestinations[i] == destination) {
return i;
}
}
return 0;
}
Widget _screenFor(AppDestination destination) {
switch (destination) {
case AppDestination.shareInbox:
return const ShareInboxView();
case AppDestination.people:
return const PeopleView();
case AppDestination.aiReview:
return const AiDigestReviewView();
case AppDestination.settings:
return const SettingsView();
}
}
}
class _Sidebar extends StatelessWidget {
const _Sidebar({
required this.selected,
required this.destinations,
required this.onChange,
});
final AppDestination selected;
final List<AppDestination> destinations;
final ValueChanged<AppDestination> onChange;
@override
Widget build(BuildContext context) {
return Container(
width: 290,
margin: const EdgeInsets.fromLTRB(20, 20, 8, 20),
padding: const EdgeInsets.fromLTRB(18, 20, 18, 18),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.78),
borderRadius: BorderRadius.circular(28),
border: Border.all(color: Colors.white.withValues(alpha: 0.7)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
gradient: const LinearGradient(
colors: <Color>[Color(0xFF1AB6C8), Color(0xFF286DE0)],
),
),
child: const Icon(
Icons.favorite_rounded,
color: Colors.white,
size: 20,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Relationship Saver',
style: Theme.of(context).textTheme.titleMedium,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 22),
...destinations.map((AppDestination destination) {
final bool isSelected = destination == selected;
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: InkWell(
onTap: () => onChange(destination),
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration(
color: isSelected
? const Color(0xFFEAF7FB)
: Colors.transparent,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: <Widget>[
Icon(
destination.icon,
size: 20,
color: isSelected
? AppTheme.primary
: AppTheme.textSecondary,
),
const SizedBox(width: 10),
Text(
destination.label,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(
color: isSelected
? AppTheme.primary
: AppTheme.textSecondary,
),
),
],
),
),
),
);
}),
const Spacer(),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF2F8FB),
borderRadius: BorderRadius.circular(16),
),
child: Text(
'Share messages or paste text into Capture. Nothing is assigned until you choose a person.',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
),
),
],
),
);
}
}
class _CompactShell extends StatelessWidget {
const _CompactShell({
required this.selected,
required this.body,
required this.destinations,
required this.onChange,
});
final AppDestination selected;
final Widget body;
final List<AppDestination> destinations;
final ValueChanged<AppDestination> onChange;
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
layoutBuilder: _expandedSwitcherLayout,
child: KeyedSubtree(
key: ValueKey<AppDestination>(selected),
child: body,
),
),
),
NavigationBar(
selectedIndex: destinations.indexOf(selected),
onDestinationSelected: (int index) => onChange(destinations[index]),
destinations: destinations
.map(
(AppDestination destination) => NavigationDestination(
icon: Icon(destination.icon),
label: destination.label,
),
)
.toList(growable: false),
),
],
);
}
}
Widget _expandedSwitcherLayout(
Widget? currentChild,
List<Widget> previousChildren,
) {
return Stack(
fit: StackFit.expand,
children: <Widget>[...previousChildren, ?currentChild],
);
}
+63
View File
@@ -0,0 +1,63 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/app/presentation/app_shell.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/core/llm/llm_config.dart';
import 'package:relationship_saver/features/ai_digest/application/llm_digest_background_scheduler.dart';
import 'package:relationship_saver/features/ai_digest/presentation/ai_digest_notification_listener.dart';
import 'package:relationship_saver/features/auth/application/session_controller.dart';
import 'package:relationship_saver/features/auth/presentation/sign_in_view.dart';
import 'package:relationship_saver/features/reminders/application/reminder_notification_listener.dart';
import 'package:relationship_saver/features/share_intake/presentation/incoming_share_listener.dart';
import 'package:relationship_saver/features/sync/application/sync_background_runner.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
/// Root app widget that wires auth, shell entry, and global listeners.
class RelationshipSaverApp extends ConsumerStatefulWidget {
const RelationshipSaverApp({super.key});
@override
ConsumerState<RelationshipSaverApp> createState() =>
_RelationshipSaverAppState();
}
class _RelationshipSaverAppState extends ConsumerState<RelationshipSaverApp> {
@override
void initState() {
super.initState();
unawaited(ref.read(llmConfigProvider.notifier).initialize());
}
@override
Widget build(BuildContext context) {
final WidgetRef ref = this.ref;
final AsyncValue<AuthSession?> sessionState = ref.watch(
sessionControllerProvider,
);
return MaterialApp(
title: 'Relationship Saver',
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
home: Scaffold(
body: sessionState.when(
data: (AuthSession? session) => session == null
? const SignInView()
: const IncomingShareListener(
child: ReminderNotificationListener(
child: AiDigestNotificationListener(
child: LlmDigestBackgroundRunner(
child: SyncBackgroundRunner(child: AppShell()),
),
),
),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) => const SignInView(),
),
),
);
}
}
+7
View File
@@ -0,0 +1,7 @@
# App State
`local_data_state.dart` is the aggregate snapshot that the prototype persists.
Treat it as composition, not as a domain home. New models should usually be
added in a feature slice first, then referenced from the aggregate state if the
prototype store needs them persisted.
+233
View File
@@ -0,0 +1,233 @@
// ignore_for_file: sort_constructors_first
import 'package:flutter/foundation.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:relationship_saver/features/dashboard/domain/dashboard_models.dart';
import 'package:relationship_saver/features/ideas/domain/idea_models.dart';
import 'package:relationship_saver/features/moments/domain/moment_models.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
import 'package:relationship_saver/features/reminders/domain/reminder_models.dart';
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
/// Aggregate local snapshot used by the prototype app store.
///
/// During this prototyping phase the app still uses one persisted snapshot for
/// most product state. The types inside it are now feature-owned, so each slice
/// can evolve without hiding its models in a horizontal "local" module.
@immutable
class LocalDataState {
const LocalDataState({
required this.people,
required this.moments,
required this.ideas,
required this.reminders,
required this.tasks,
this.dismissedSignalIds = const <String>[],
this.sourceLinks = const <SourceProfileLink>[],
this.sharedMessages = const <SharedMessageEntry>[],
this.sharedInbox = const <SharedInboxEntry>[],
this.personFacts = const <PersonFact>[],
this.importantDates = const <PersonImportantDate>[],
this.preferenceSignals = const <PersonPreferenceSignal>[],
this.aiSuggestionDrafts = const <AiSuggestionDraft>[],
});
final List<PersonProfile> people;
final List<RelationshipMoment> moments;
final List<RelationshipIdea> ideas;
final List<ReminderRule> reminders;
final List<DashboardTask> tasks;
final List<String> dismissedSignalIds;
final List<SourceProfileLink> sourceLinks;
final List<SharedMessageEntry> sharedMessages;
final List<SharedInboxEntry> sharedInbox;
final List<PersonFact> personFacts;
final List<PersonImportantDate> importantDates;
final List<PersonPreferenceSignal> preferenceSignals;
final List<AiSuggestionDraft> aiSuggestionDrafts;
LocalDataState copyWith({
List<PersonProfile>? people,
List<RelationshipMoment>? moments,
List<RelationshipIdea>? ideas,
List<ReminderRule>? reminders,
List<DashboardTask>? tasks,
List<String>? dismissedSignalIds,
List<SourceProfileLink>? sourceLinks,
List<SharedMessageEntry>? sharedMessages,
List<SharedInboxEntry>? sharedInbox,
List<PersonFact>? personFacts,
List<PersonImportantDate>? importantDates,
List<PersonPreferenceSignal>? preferenceSignals,
List<AiSuggestionDraft>? aiSuggestionDrafts,
}) {
return LocalDataState(
people: people ?? this.people,
moments: moments ?? this.moments,
ideas: ideas ?? this.ideas,
reminders: reminders ?? this.reminders,
tasks: tasks ?? this.tasks,
dismissedSignalIds: dismissedSignalIds ?? this.dismissedSignalIds,
sourceLinks: sourceLinks ?? this.sourceLinks,
sharedMessages: sharedMessages ?? this.sharedMessages,
sharedInbox: sharedInbox ?? this.sharedInbox,
personFacts: personFacts ?? this.personFacts,
importantDates: importantDates ?? this.importantDates,
preferenceSignals: preferenceSignals ?? this.preferenceSignals,
aiSuggestionDrafts: aiSuggestionDrafts ?? this.aiSuggestionDrafts,
);
}
DashboardSummary summary({DateTime? now}) {
final DateTime baseline = now ?? DateTime.now();
return DashboardSummary(
activePeople: people.length,
upcomingPlans: people
.where((PersonProfile p) => p.nextMoment.isAfter(baseline))
.length,
pendingIdeas: ideas
.where((RelationshipIdea idea) => !idea.isArchived)
.length,
weeklyConsistency: moments.isEmpty
? 0
: (70 + moments.length * 4).clamp(0, 100),
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'people': people
.map((PersonProfile person) => person.toJson())
.toList(growable: false),
'moments': moments
.map((RelationshipMoment moment) => moment.toJson())
.toList(growable: false),
'ideas': ideas
.map((RelationshipIdea idea) => idea.toJson())
.toList(growable: false),
'reminders': reminders
.map((ReminderRule reminder) => reminder.toJson())
.toList(growable: false),
'tasks': tasks
.map((DashboardTask task) => task.toJson())
.toList(growable: false),
'dismissedSignalIds': dismissedSignalIds,
'sourceLinks': sourceLinks
.map((SourceProfileLink link) => link.toJson())
.toList(growable: false),
'sharedMessages': sharedMessages
.map((SharedMessageEntry entry) => entry.toJson())
.toList(growable: false),
'sharedInbox': sharedInbox
.map((SharedInboxEntry entry) => entry.toJson())
.toList(growable: false),
'personFacts': personFacts
.map((PersonFact fact) => fact.toJson())
.toList(growable: false),
'importantDates': importantDates
.map((PersonImportantDate value) => value.toJson())
.toList(growable: false),
'preferenceSignals': preferenceSignals
.map((PersonPreferenceSignal signal) => signal.toJson())
.toList(growable: false),
'aiSuggestionDrafts': aiSuggestionDrafts
.map((AiSuggestionDraft draft) => draft.toJson())
.toList(growable: false),
};
}
factory LocalDataState.fromJson(Map<String, dynamic> json) {
return LocalDataState(
people: (json['people'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic person) =>
PersonProfile.fromJson(person as Map<String, dynamic>),
)
.toList(growable: false),
moments: (json['moments'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic moment) =>
RelationshipMoment.fromJson(moment as Map<String, dynamic>),
)
.toList(growable: false),
ideas: (json['ideas'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic idea) =>
RelationshipIdea.fromJson(idea as Map<String, dynamic>),
)
.toList(growable: false),
reminders: (json['reminders'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic reminder) =>
ReminderRule.fromJson(reminder as Map<String, dynamic>),
)
.toList(growable: false),
tasks: (json['tasks'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic task) =>
DashboardTask.fromJson(task as Map<String, dynamic>),
)
.toList(growable: false),
dismissedSignalIds:
(json['dismissedSignalIds'] as List<dynamic>? ?? <dynamic>[])
.map((dynamic value) => '$value')
.toList(growable: false),
sourceLinks: (json['sourceLinks'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic link) =>
SourceProfileLink.fromJson(link as Map<String, dynamic>),
)
.toList(growable: false),
sharedMessages: (json['sharedMessages'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic entry) =>
SharedMessageEntry.fromJson(entry as Map<String, dynamic>),
)
.toList(growable: false),
sharedInbox: (json['sharedInbox'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic entry) =>
SharedInboxEntry.fromJson(entry as Map<String, dynamic>),
)
.toList(growable: false),
personFacts: (json['personFacts'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic fact) => PersonFact.fromJson(fact as Map<String, dynamic>),
)
.toList(growable: false),
importantDates: (json['importantDates'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic value) =>
PersonImportantDate.fromJson(value as Map<String, dynamic>),
)
.toList(growable: false),
preferenceSignals:
(json['preferenceSignals'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic signal) => PersonPreferenceSignal.fromJson(
signal as Map<String, dynamic>,
),
)
.toList(growable: false),
aiSuggestionDrafts:
(json['aiSuggestionDrafts'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic draft) =>
AiSuggestionDraft.fromJson(draft as Map<String, dynamic>),
)
.toList(growable: false),
);
}
static LocalDataState empty() {
return const LocalDataState(
people: <PersonProfile>[],
moments: <RelationshipMoment>[],
ideas: <RelationshipIdea>[],
reminders: <ReminderRule>[],
tasks: <DashboardTask>[],
);
}
static LocalDataState seed() => empty();
}
+11
View File
@@ -0,0 +1,11 @@
# Core
`lib/core/` is for truly cross-cutting code:
- config and theme
- auth primitives
- network clients and interceptors
- small shared utilities
- shared presentation primitives
If a file mainly exists for one slice, keep it in that slice instead.
+4
View File
@@ -0,0 +1,4 @@
# Core Auth
Cross-cutting auth primitives live here. Keep this folder limited to reusable
infrastructure rather than slice-specific sign-in UI or session flows.
+4
View File
@@ -0,0 +1,4 @@
# Core Config
App-wide configuration, feature flags, and theme-level constants live here.
Use this folder for global configuration only, not feature-owned defaults.
+38 -1
View File
@@ -9,6 +9,22 @@ class AppConfig {
static String? _baseUrlOverride;
static const String _defaultSentryDsn = String.fromEnvironment(
'SENTRY_DSN',
defaultValue:
'https://97fb0a56bc35440fba0ba139dc3b1ccc@bugs.zuzo.ch/2',
);
static const String _defaultSentryEnvironment = String.fromEnvironment(
'SENTRY_ENVIRONMENT',
defaultValue: '',
);
static const String _defaultSentryRelease = String.fromEnvironment(
'SENTRY_RELEASE',
defaultValue: '',
);
/// Returns the configured backend base URL.
static String get backendBaseUrl {
final String? override = _baseUrlOverride;
@@ -28,7 +44,7 @@ class AppConfig {
/// Enables periodic startup/resume sync triggers while authenticated.
static bool get enableBackgroundSync =>
const bool.fromEnvironment('ENABLE_BACKGROUND_SYNC', defaultValue: true);
const bool.fromEnvironment('ENABLE_BACKGROUND_SYNC', defaultValue: false);
/// Enables local notifications runtime for reminder delivery.
static bool get enableLocalNotifications => const bool.fromEnvironment(
@@ -48,6 +64,27 @@ class AppConfig {
defaultValue: 180,
);
/// Sentry-compatible DSN for release crash/error reporting.
static String get sentryDsn => _defaultSentryDsn.trim();
/// Sentry environment label.
static String get sentryEnvironment {
final String configured = _defaultSentryEnvironment.trim();
if (configured.isNotEmpty) {
return configured;
}
return useFakeBackend ? 'development' : 'production';
}
/// Optional Sentry release identifier.
static String? get sentryRelease {
final String configured = _defaultSentryRelease.trim();
if (configured.isEmpty) {
return null;
}
return configured;
}
/// Runtime override for backend URL (e.g. local settings screen).
static void overrideBackendBaseUrl(String? baseUrl) {
_baseUrlOverride = baseUrl;
@@ -0,0 +1,73 @@
import 'dart:convert';
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
CapturedFactDraft? parseCapturedFactDraftSuggestion(
String response, {
required SharedPayload fallbackPayload,
}) {
try {
final int jsonStart = response.indexOf('{');
final int jsonEnd = response.lastIndexOf('}');
if (jsonStart == -1 || jsonEnd == -1 || jsonEnd <= jsonStart) {
return null;
}
final Map<String, dynamic> json =
jsonDecode(response.substring(jsonStart, jsonEnd + 1))
as Map<String, dynamic>;
final String typeName = '${json['type'] ?? CapturedFactType.note.name}';
final DateTime? parsedDate = _parseDate(json['dateValue']);
final double? confidence = _parseConfidence(json['confidence']);
return CapturedFactDraft(
type: CapturedFactType.values.firstWhere(
(CapturedFactType type) => type.name == typeName,
orElse: () => CapturedFactType.note,
),
text: '${json['text'] ?? fallbackPayload.rawText}'.trim(),
label: _trimToNull(json['label'] as String?),
dateValue: parsedDate,
confidence: confidence,
isSensitive: json['isSensitive'] as bool? ?? false,
needsReview: true,
);
} catch (_) {
return null;
}
}
String? _trimToNull(String? value) {
if (value == null) {
return null;
}
final String trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
DateTime? _parseDate(Object? raw) {
if (raw == null) {
return null;
}
final String value = '$raw'.trim();
if (value.isEmpty) {
return null;
}
return DateTime.tryParse(value)?.toLocal();
}
double? _parseConfidence(Object? raw) {
if (raw == null) {
return null;
}
final double? value = switch (raw) {
final num number => number.toDouble(),
final String text => double.tryParse(text),
_ => null,
};
if (value == null) {
return null;
}
final num clamped = value.clamp(0, 1);
return clamped.toDouble();
}
+291
View File
@@ -0,0 +1,291 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
const String _llmApiKeyStorageKey = 'llm_api_key';
const String _llmBaseUrlStorageKey = 'llm_base_url';
const String _llmModelStorageKey = 'llm_model';
const String _llmProviderStorageKey = 'llm_provider';
String _providerApiKeyStorageKey(LlmProvider provider) {
return 'llm_api_key_${provider.name}';
}
enum LlmProvider { openai, anthropic, google, ollama, openaiCompatible }
extension LlmProviderMetadata on LlmProvider {
String get label {
return switch (this) {
LlmProvider.openai => 'OpenAI',
LlmProvider.anthropic => 'Anthropic',
LlmProvider.google => 'Google AI',
LlmProvider.ollama => 'Ollama',
LlmProvider.openaiCompatible => 'OpenAI-compatible',
};
}
String get defaultBaseUrl {
return switch (this) {
LlmProvider.openai => 'https://api.openai.com/v1',
LlmProvider.anthropic => 'https://api.anthropic.com/v1',
LlmProvider.google => 'https://generativelanguage.googleapis.com/v1beta',
LlmProvider.ollama => 'http://localhost:11434',
LlmProvider.openaiCompatible => '',
};
}
String get defaultModel {
return switch (this) {
LlmProvider.openai => 'gpt-4o-mini',
LlmProvider.anthropic => 'claude-3-haiku-20240307',
LlmProvider.google => 'gemini-2.0-flash',
LlmProvider.ollama => '',
LlmProvider.openaiCompatible => '',
};
}
bool get requiresApiKey {
return switch (this) {
LlmProvider.openai || LlmProvider.anthropic || LlmProvider.google => true,
LlmProvider.ollama || LlmProvider.openaiCompatible => false,
};
}
bool get hasConfigurableBaseUrl {
return this == LlmProvider.ollama || this == LlmProvider.openaiCompatible;
}
}
class LlmConfigState {
LlmConfigState({
bool apiKeyConfigured = false,
LlmProvider provider = LlmProvider.openai,
String? baseUrl,
String? model,
}) : this._(
apiKeyConfigured: apiKeyConfigured,
provider: provider,
baseUrl: _resolveBaseUrl(provider, baseUrl),
model: _resolveModel(provider, model),
);
const LlmConfigState._({
required this.apiKeyConfigured,
required this.provider,
required this.baseUrl,
required this.model,
});
final bool apiKeyConfigured;
final LlmProvider provider;
final String baseUrl;
final String model;
bool get isConfigured {
final bool hasKey = apiKeyConfigured || !provider.requiresApiKey;
return hasKey && baseUrl.trim().isNotEmpty && model.trim().isNotEmpty;
}
LlmConfigState copyWith({
bool? apiKeyConfigured,
LlmProvider? provider,
String? baseUrl,
String? model,
}) {
final LlmProvider nextProvider = provider ?? this.provider;
return LlmConfigState(
apiKeyConfigured: apiKeyConfigured ?? this.apiKeyConfigured,
provider: nextProvider,
baseUrl: baseUrl ?? this.baseUrl,
model: model ?? this.model,
);
}
static String _resolveBaseUrl(LlmProvider provider, String? baseUrl) {
final String? trimmed = baseUrl?.trim();
if (trimmed != null && trimmed.isNotEmpty) {
return trimmed;
}
return provider.defaultBaseUrl;
}
static String _resolveModel(LlmProvider provider, String? model) {
final String? trimmed = model?.trim();
if (trimmed != null && trimmed.isNotEmpty) {
return trimmed;
}
return provider.defaultModel;
}
}
class LlmConfigNotifier extends Notifier<LlmConfigState> {
Future<void>? _initializeFuture;
@override
LlmConfigState build() {
return LlmConfigState();
}
Future<void> initialize() async {
final Future<void>? inFlight = _initializeFuture;
if (inFlight != null) {
return inFlight;
}
_initializeFuture = _initializeFromStorage();
return _initializeFuture;
}
Future<void> _initializeFromStorage() async {
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
final String? storedProvider = await secureStorage.read(
key: _llmProviderStorageKey,
);
final String? storedBaseUrl = await secureStorage.read(
key: _llmBaseUrlStorageKey,
);
final String? storedModel = await secureStorage.read(
key: _llmModelStorageKey,
);
final LlmProvider provider = storedProvider != null
? LlmProvider.values.firstWhere(
(LlmProvider p) => p.name == storedProvider,
orElse: () => LlmProvider.openai,
)
: LlmProvider.openai;
String? storedKey = await _readApiKey(secureStorage, provider: provider);
if (storedKey == null || storedKey.isEmpty) {
final String? legacyKey = await secureStorage.read(
key: _llmApiKeyStorageKey,
);
if (legacyKey != null && legacyKey.isNotEmpty) {
await secureStorage.write(
key: _providerApiKeyStorageKey(provider),
value: legacyKey,
);
storedKey = legacyKey;
}
}
state = LlmConfigState(
apiKeyConfigured: storedKey != null && storedKey.isNotEmpty,
provider: provider,
baseUrl: storedBaseUrl,
model: storedModel,
);
}
Future<void> setApiKey(String apiKey) async {
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
final String key = _providerApiKeyStorageKey(state.provider);
if (apiKey.isEmpty) {
await secureStorage.delete(key: key);
} else {
await secureStorage.write(key: key, value: apiKey);
}
state = state.copyWith(apiKeyConfigured: apiKey.isNotEmpty);
}
Future<void> setProvider(LlmProvider provider) async {
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
await secureStorage.write(
key: _llmProviderStorageKey,
value: provider.name,
);
await secureStorage.write(
key: _llmBaseUrlStorageKey,
value: provider.defaultBaseUrl,
);
await secureStorage.write(
key: _llmModelStorageKey,
value: provider.defaultModel,
);
final String? storedKey = await _readApiKey(
secureStorage,
provider: provider,
);
state = state.copyWith(
apiKeyConfigured: storedKey != null && storedKey.isNotEmpty,
provider: provider,
baseUrl: provider.defaultBaseUrl,
model: provider.defaultModel,
);
}
Future<void> saveConfiguration({
required LlmProvider provider,
required String baseUrl,
required String model,
String? apiKey,
bool clearApiKey = false,
}) async {
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
await secureStorage.write(
key: _llmProviderStorageKey,
value: provider.name,
);
await secureStorage.write(
key: _llmBaseUrlStorageKey,
value: baseUrl.trim(),
);
await secureStorage.write(key: _llmModelStorageKey, value: model.trim());
final String? storedProviderKey = await _readApiKey(
secureStorage,
provider: provider,
);
bool apiKeyConfigured =
storedProviderKey != null && storedProviderKey.isNotEmpty;
if (clearApiKey) {
await secureStorage.delete(key: _providerApiKeyStorageKey(provider));
apiKeyConfigured = false;
} else if (apiKey != null) {
final String trimmedApiKey = apiKey.trim();
if (trimmedApiKey.isEmpty) {
await secureStorage.delete(key: _providerApiKeyStorageKey(provider));
apiKeyConfigured = false;
} else {
await secureStorage.write(
key: _providerApiKeyStorageKey(provider),
value: trimmedApiKey,
);
apiKeyConfigured = true;
}
}
state = LlmConfigState(
apiKeyConfigured: apiKeyConfigured,
provider: provider,
baseUrl: baseUrl,
model: model,
);
}
Future<String?> getApiKey({LlmProvider? provider}) async {
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
return _readApiKey(secureStorage, provider: provider ?? state.provider);
}
Future<void> clearApiKey() async {
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
await secureStorage.delete(key: _providerApiKeyStorageKey(state.provider));
state = state.copyWith(apiKeyConfigured: false);
}
Future<String?> _readApiKey(
FlutterSecureStorage secureStorage, {
required LlmProvider provider,
}) async {
final String? providerKey = await secureStorage.read(
key: _providerApiKeyStorageKey(provider),
);
if (providerKey != null && providerKey.isNotEmpty) {
return providerKey;
}
return null;
}
}
final llmConfigProvider = NotifierProvider<LlmConfigNotifier, LlmConfigState>(
LlmConfigNotifier.new,
);
+118
View File
@@ -0,0 +1,118 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
const String _llmDiagnosticsLogKey = 'llm_diagnostics_log_v1';
const int _maxLlmDiagnosticsEvents = 40;
const int _maxLlmDiagnosticsDetailChars = 8000;
class LlmDiagnosticsEvent {
const LlmDiagnosticsEvent({
required this.at,
required this.stage,
required this.message,
this.details,
});
factory LlmDiagnosticsEvent.fromJson(Map<String, dynamic> json) {
return LlmDiagnosticsEvent(
at: DateTime.parse(
json['at'] as String? ?? DateTime.now().toUtc().toIso8601String(),
).toLocal(),
stage: json['stage'] as String? ?? 'unknown',
message: json['message'] as String? ?? '',
details: json['details'] as String?,
);
}
final DateTime at;
final String stage;
final String message;
final String? details;
Map<String, dynamic> toJson() {
return <String, dynamic>{
'at': at.toUtc().toIso8601String(),
'stage': stage,
'message': message,
if (details != null) 'details': details,
};
}
}
class LlmDiagnosticsLog {
const LlmDiagnosticsLog();
Future<List<LlmDiagnosticsEvent>> read() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
final String? raw = prefs.getString(_llmDiagnosticsLogKey);
if (raw == null || raw.isEmpty) {
return const <LlmDiagnosticsEvent>[];
}
try {
final List<dynamic> items = jsonDecode(raw) as List<dynamic>;
return items
.whereType<Map<String, dynamic>>()
.map(LlmDiagnosticsEvent.fromJson)
.toList(growable: false);
} on FormatException {
return const <LlmDiagnosticsEvent>[];
}
}
Future<void> record(String stage, String message, {String? details}) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
final List<LlmDiagnosticsEvent> current = await read();
final List<LlmDiagnosticsEvent> next = <LlmDiagnosticsEvent>[
LlmDiagnosticsEvent(
at: DateTime.now(),
stage: stage,
message: message,
details: _compactDetails(details),
),
...current,
].take(_maxLlmDiagnosticsEvents).toList(growable: false);
await prefs.setString(
_llmDiagnosticsLogKey,
jsonEncode(
next
.map((LlmDiagnosticsEvent event) => event.toJson())
.toList(growable: false),
),
);
}
Future<void> clear() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.remove(_llmDiagnosticsLogKey);
}
String exportText(List<LlmDiagnosticsEvent> events) {
if (events.isEmpty) {
return 'No LLM diagnostics recorded.';
}
return events
.map((LlmDiagnosticsEvent event) {
final String details = event.details == null
? ''
: '\n${event.details}';
return '[${event.at.toIso8601String()}] ${event.stage}: ${event.message}$details';
})
.join('\n\n');
}
}
final Provider<LlmDiagnosticsLog> llmDiagnosticsLogProvider =
Provider<LlmDiagnosticsLog>((Ref ref) => const LlmDiagnosticsLog());
String? _compactDetails(String? value) {
final String? trimmed = value?.trim();
if (trimmed == null || trimmed.isEmpty) {
return null;
}
if (trimmed.length <= _maxLlmDiagnosticsDetailChars) {
return trimmed;
}
return '${trimmed.substring(0, _maxLlmDiagnosticsDetailChars)}\n[truncated]';
}
+900
View File
@@ -0,0 +1,900 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/llm/captured_fact_draft_parser.dart';
import 'package:relationship_saver/core/llm/llm_config.dart';
import 'package:relationship_saver/core/llm/llm_diagnostics_log.dart';
import 'package:relationship_saver/core/llm/ollama/ollama_tool_chat_client.dart';
import 'package:relationship_saver/core/observability/sentry_init.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
class LlmProviderException implements Exception {
const LlmProviderException({
required this.message,
required this.provider,
this.statusCode,
});
final String message;
final LlmProvider provider;
final int? statusCode;
@override
String toString() => message;
}
class LlmModelInfo {
const LlmModelInfo({required this.id, String? label}) : label = label ?? id;
final String id;
final String label;
}
class LlmService {
LlmService(this._ref);
final Ref _ref;
Future<LlmConfigState> get _config async {
final state = _ref.read(llmConfigProvider);
if (!state.isConfigured) {
throw Exception('LLM not configured');
}
return state;
}
Future<List<GeneratedSignal>> generateSignals(
List<PersonProfile> people,
) async {
final config = await _config;
final apiKey = await _ref.read(llmConfigProvider.notifier).getApiKey();
if (config.provider.requiresApiKey && (apiKey == null || apiKey.isEmpty)) {
throw Exception('No API key available');
}
final systemPrompt =
'''You are a relationship assistant that helps users maintain meaningful connections with their friends and family.
Based on the person's profile data, generate helpful signals such as:
- Check-in reminders
- Gift ideas
- Follow-up suggestions
- Relationship maintenance tips
Return your response as a JSON array of signals, each with:
- title: short actionable title
- description: brief explanation
- type: "recommendation", "gift", "followup", or "reminder"
- personId: the person's ID (use a valid ID from the input if applicable)
Only return the JSON array, no other text.''';
final peopleContext = people
.map(
(p) =>
'- ${p.name} (${p.relationship}): tags=${p.tags.join(", ")}, notes=${p.notes.isEmpty ? "none" : p.notes}, affinity=${p.affinityScore}',
)
.join('\n');
final userPrompt =
'''Here are the people in my life:
$peopleContext
Generate 3-5 helpful relationship signals for these people.''';
try {
final response = await _callLlm(
provider: config.provider,
apiKey: apiKey,
baseUrl: config.baseUrl,
model: config.model,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
);
return _parseSignalsResponse(response, people);
} catch (e) {
throw Exception('Failed to generate signals: $e');
}
}
Future<String> completeText({
required String systemPrompt,
required String userPrompt,
bool enableWebSearch = false,
}) async {
final config = await _config;
final String? apiKey = await _ref
.read(llmConfigProvider.notifier)
.getApiKey();
if (config.provider.requiresApiKey && (apiKey == null || apiKey.isEmpty)) {
throw Exception('No API key available');
}
return _callLlm(
provider: config.provider,
apiKey: apiKey,
baseUrl: config.baseUrl,
model: config.model,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
enableWebSearch: enableWebSearch,
);
}
Future<CapturedFactDraft?> suggestCaptureDraft(SharedPayload payload) async {
final config = await _config;
final String? apiKey = await _ref
.read(llmConfigProvider.notifier)
.getApiKey();
if (config.provider.requiresApiKey && (apiKey == null || apiKey.isEmpty)) {
throw Exception('No API key available');
}
final String systemPrompt =
'''You analyze shared messages and links for a relationship-tracking app.
Return exactly one JSON object with:
- type: one of "note", "like", "dislike", "importantDate", "giftIdea", "placeIdea", "activityIdea", "misc"
- text: cleaned user-facing summary
- label: short optional label
- dateValue: ISO-8601 date if the content clearly describes an important date, otherwise null
- confidence: number from 0 to 1
- isSensitive: true only if the content looks private or sensitive
Be conservative. If the content is ambiguous, prefer "note". Return JSON only.''';
final String userPrompt =
'''Source app: ${payload.sourceApp}
Sender: ${payload.sourceDisplayName ?? "unknown"}
Platform: ${payload.platform}
URL: ${payload.url ?? "none"}
shared_message_datetime: ${payload.sharedMessageDateTime?.toIso8601String() ?? "null"}
Import received at: ${payload.receivedAt.toIso8601String()}
If shared_message_datetime is present, use it as the point-in-time reference for interpreting the message. Old status or health updates should usually become historical context or durable preferences, not current check-in prompts.
Raw text:
${payload.rawText}''';
try {
final String response = await _callLlm(
provider: config.provider,
apiKey: apiKey,
baseUrl: config.baseUrl,
model: config.model,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
);
return parseCapturedFactDraftSuggestion(
response,
fallbackPayload: payload,
);
} catch (e) {
throw Exception('Failed to suggest share capture draft: $e');
}
}
Future<List<LlmModelInfo>> listAvailableModels({
required LlmProvider provider,
String? apiKey,
String? baseUrl,
}) async {
final String? resolvedApiKey = apiKey != null && apiKey.trim().isNotEmpty
? apiKey.trim()
: await _ref
.read(llmConfigProvider.notifier)
.getApiKey(provider: provider);
if (provider.requiresApiKey &&
(resolvedApiKey == null || resolvedApiKey.isEmpty)) {
throw LlmProviderException(
provider: provider,
message: '${provider.label} needs an API key before models can load.',
);
}
final Dio dio = _createDio();
final String resolvedBaseUrl = _normalizeBaseUrl(
baseUrl?.trim().isNotEmpty == true
? baseUrl!.trim()
: provider.defaultBaseUrl,
);
try {
return switch (provider) {
LlmProvider.openai => _listOpenAIModels(
dio,
resolvedBaseUrl,
resolvedApiKey,
),
LlmProvider.anthropic => _listAnthropicModels(
dio,
resolvedBaseUrl,
resolvedApiKey,
),
LlmProvider.google => _listGoogleModels(
dio,
resolvedBaseUrl,
resolvedApiKey,
),
LlmProvider.ollama => _listOllamaModels(dio, resolvedBaseUrl),
LlmProvider.openaiCompatible => _listOpenAIModels(
dio,
resolvedBaseUrl,
resolvedApiKey,
),
};
} on DioException catch (error) {
throw _mapProviderError(provider, error);
}
}
Future<String> _callLlm({
required LlmProvider provider,
required String? apiKey,
required String baseUrl,
required String model,
required String systemPrompt,
required String userPrompt,
bool enableWebSearch = false,
}) async {
final Dio dio = provider == LlmProvider.ollama
? _createDio(receiveTimeout: const Duration(minutes: 5))
: _createDio();
final String resolvedBaseUrl = _normalizeBaseUrl(baseUrl);
await _recordDiagnostics(
'request_start',
'Starting ${provider.label} LLM request.',
details: _diagnosticsDetails(
provider: provider,
baseUrl: resolvedBaseUrl,
model: model,
enableWebSearch: enableWebSearch,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
),
);
try {
late final String content;
switch (provider) {
case LlmProvider.openai:
content = await _callOpenAI(
dio,
resolvedBaseUrl,
apiKey,
model,
systemPrompt,
userPrompt,
);
case LlmProvider.anthropic:
content = await _callAnthropic(
dio,
resolvedBaseUrl,
apiKey,
model,
systemPrompt,
userPrompt,
);
case LlmProvider.google:
content = await _callGoogleAI(
dio,
resolvedBaseUrl,
apiKey,
model,
systemPrompt,
userPrompt,
enableWebSearch: enableWebSearch,
);
case LlmProvider.ollama:
content = await _callOllama(
dio,
resolvedBaseUrl,
model,
systemPrompt,
userPrompt,
enableWebSearch: enableWebSearch,
);
case LlmProvider.openaiCompatible:
content = await _callOpenAI(
dio,
resolvedBaseUrl,
apiKey,
model,
systemPrompt,
userPrompt,
);
}
await _recordDiagnostics(
'request_success',
'${provider.label} returned a response.',
details: _diagnosticsDetails(
provider: provider,
baseUrl: resolvedBaseUrl,
model: model,
enableWebSearch: enableWebSearch,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
responseLength: content.length,
),
);
return content;
} on DioException catch (error) {
await _recordProviderFailure(
provider: provider,
baseUrl: resolvedBaseUrl,
model: model,
enableWebSearch: enableWebSearch,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
error: error,
);
throw _mapProviderError(provider, error);
}
}
Future<void> _recordDiagnostics(
String stage,
String message, {
String? details,
}) async {
try {
await _ref
.read(llmDiagnosticsLogProvider)
.record(stage, message, details: details);
} catch (_) {
// Diagnostics must never break the user-facing LLM flow.
}
}
Future<void> _recordProviderFailure({
required LlmProvider provider,
required String baseUrl,
required String model,
required bool enableWebSearch,
required String systemPrompt,
required String userPrompt,
required DioException error,
}) {
final int? statusCode = error.response?.statusCode;
final String status = statusCode == null
? 'without HTTP status'
: '$statusCode';
final Map<String, dynamic> context = _diagnosticsMap(
provider: provider,
baseUrl: baseUrl,
model: model,
enableWebSearch: enableWebSearch,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
dioType: error.type.name,
statusCode: statusCode,
providerMessage: _extractProviderMessage(error.response?.data),
responseBody: _safeJson(error.response?.data),
);
_ref
.read(sentryInitProvider)
.captureException(
error,
stackTrace: error.stackTrace,
area: 'llm_provider',
context: context,
);
return _recordDiagnostics(
'provider_error',
'${provider.label} request failed with $status.',
details: const JsonEncoder.withIndent(' ').convert(context),
);
}
Future<String> _callOpenAI(
Dio dio,
String baseUrl,
String? apiKey,
String model,
String systemPrompt,
String userPrompt,
) async {
final Map<String, String> headers = <String, String>{
'Content-Type': 'application/json',
if (apiKey != null && apiKey.isNotEmpty)
'Authorization': 'Bearer $apiKey',
};
final Response<Map<String, dynamic>> response = await dio
.post<Map<String, dynamic>>(
'$baseUrl/chat/completions',
options: Options(headers: headers),
data: jsonEncode({
'model': model,
'messages': [
{'role': 'system', 'content': systemPrompt},
{'role': 'user', 'content': userPrompt},
],
'max_tokens': 1000,
}),
);
final Map<String, dynamic> responseData = response.data!;
final List<dynamic> choices = responseData['choices'] as List<dynamic>;
final Map<String, dynamic> firstChoice = choices[0] as Map<String, dynamic>;
final Map<String, dynamic> message =
firstChoice['message'] as Map<String, dynamic>;
final String content = message['content'] as String;
return content;
}
Future<String> _callAnthropic(
Dio dio,
String baseUrl,
String? apiKey,
String model,
String systemPrompt,
String userPrompt,
) async {
final Response<Map<String, dynamic>> response = await dio
.post<Map<String, dynamic>>(
'$baseUrl/messages',
options: Options(
headers: {
'x-api-key': apiKey ?? '',
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
},
),
data: jsonEncode({
'model': model,
'system': systemPrompt,
'messages': [
{'role': 'user', 'content': userPrompt},
],
'max_tokens': 1000,
}),
);
final Map<String, dynamic> responseData = response.data!;
final List<dynamic> contentList = responseData['content'] as List<dynamic>;
final Map<String, dynamic> firstContent =
contentList[0] as Map<String, dynamic>;
final String content = firstContent['text'] as String;
return content;
}
Future<String> _callGoogleAI(
Dio dio,
String baseUrl,
String? apiKey,
String model,
String systemPrompt,
String userPrompt, {
bool enableWebSearch = false,
}) async {
final String modelPath = model.startsWith('models/')
? model
: 'models/$model';
final Response<Map<String, dynamic>> response = await dio
.post<Map<String, dynamic>>(
'$baseUrl/$modelPath:generateContent',
options: Options(headers: {'Content-Type': 'application/json'}),
queryParameters: {'key': apiKey ?? ''},
data: jsonEncode({
'systemInstruction': {
'parts': [
{'text': systemPrompt},
],
},
'contents': [
{
'parts': [
{'text': userPrompt},
],
},
],
if (enableWebSearch)
'tools': [
{'google_search': <String, dynamic>{}},
],
'generationConfig': {'temperature': 0.7, 'maxOutputTokens': 1000},
}),
);
final Map<String, dynamic> responseData = response.data!;
final List<dynamic> candidates =
responseData['candidates'] as List<dynamic>;
final Map<String, dynamic> firstCandidate =
candidates[0] as Map<String, dynamic>;
final Map<String, dynamic> content =
firstCandidate['content'] as Map<String, dynamic>;
final List<dynamic> parts = content['parts'] as List<dynamic>;
final Map<String, dynamic> firstPart = parts[0] as Map<String, dynamic>;
final String contentText = firstPart['text'] as String;
return contentText;
}
Future<String> _callOllama(
Dio dio,
String baseUrl,
String model,
String systemPrompt,
String userPrompt, {
bool enableWebSearch = false,
}) async {
if (enableWebSearch) {
return _ref
.read(ollamaToolChatClientProvider)
.complete(
baseUrl: baseUrl,
model: model,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
);
}
final Response<Map<String, dynamic>> response = await dio
.post<Map<String, dynamic>>(
'$baseUrl/api/chat',
options: Options(headers: {'Content-Type': 'application/json'}),
data: jsonEncode({
'model': model,
'stream': false,
'messages': [
{'role': 'system', 'content': systemPrompt},
{'role': 'user', 'content': userPrompt},
],
}),
);
final Map<String, dynamic> responseData = response.data!;
final Map<String, dynamic> message =
responseData['message'] as Map<String, dynamic>;
return message['content'] as String;
}
Future<List<LlmModelInfo>> _listOpenAIModels(
Dio dio,
String baseUrl,
String? apiKey,
) async {
final Response<Map<String, dynamic>> response = await dio
.get<Map<String, dynamic>>(
'$baseUrl/models',
options: Options(
headers: <String, String>{
if (apiKey != null && apiKey.isNotEmpty)
'Authorization': 'Bearer $apiKey',
},
),
);
final List<dynamic> data = response.data!['data'] as List<dynamic>;
return _sortModels(
data
.map((dynamic item) => item as Map<String, dynamic>)
.map((Map<String, dynamic> item) => item['id'] as String)
.map((String id) => LlmModelInfo(id: id)),
);
}
Future<List<LlmModelInfo>> _listAnthropicModels(
Dio dio,
String baseUrl,
String? apiKey,
) async {
final Response<Map<String, dynamic>> response = await dio
.get<Map<String, dynamic>>(
'$baseUrl/models',
options: Options(
headers: <String, String>{
'x-api-key': apiKey ?? '',
'anthropic-version': '2023-06-01',
},
),
);
final List<dynamic> data = response.data!['data'] as List<dynamic>;
return _sortModels(
data.map((dynamic item) {
final Map<String, dynamic> model = item as Map<String, dynamic>;
final String id = model['id'] as String;
return LlmModelInfo(id: id, label: model['display_name'] as String?);
}),
);
}
Future<List<LlmModelInfo>> _listGoogleModels(
Dio dio,
String baseUrl,
String? apiKey,
) async {
final Response<Map<String, dynamic>> response = await dio
.get<Map<String, dynamic>>(
'$baseUrl/models',
queryParameters: <String, String>{'key': apiKey ?? ''},
);
final List<dynamic> data = response.data!['models'] as List<dynamic>;
return _sortModels(
data
.map((dynamic item) => item as Map<String, dynamic>)
.where((Map<String, dynamic> item) {
final Object? methods = item['supportedGenerationMethods'];
return methods is List<dynamic> &&
methods.whereType<String>().contains('generateContent');
})
.map((Map<String, dynamic> item) {
final String name = item['name'] as String;
final String id = name.startsWith('models/')
? name.substring('models/'.length)
: name;
return LlmModelInfo(
id: id,
label: item['displayName'] as String? ?? id,
);
}),
);
}
Future<List<LlmModelInfo>> _listOllamaModels(Dio dio, String baseUrl) async {
final Response<Map<String, dynamic>> response = await dio
.get<Map<String, dynamic>>('$baseUrl/api/tags');
final List<dynamic> data = response.data!['models'] as List<dynamic>;
return _sortModels(
data.map((dynamic item) => item as Map<String, dynamic>).map((
Map<String, dynamic> item,
) {
final Object? name = item['name'] ?? item['model'];
return LlmModelInfo(id: name as String);
}),
);
}
List<GeneratedSignal> _parseSignalsResponse(
String response,
List<PersonProfile> people,
) {
try {
final jsonStart = response.indexOf('[');
final jsonEnd = response.lastIndexOf(']');
if (jsonStart == -1 || jsonEnd == -1) {
return [];
}
final jsonStr = response.substring(jsonStart, jsonEnd + 1);
final List<dynamic> items = jsonDecode(jsonStr) as List<dynamic>;
return items.map((item) {
final Map<String, dynamic> itemMap = item as Map<String, dynamic>;
final String? personId = itemMap['personId'] as String?;
final validPersonId =
personId != null && people.any((p) => p.id == personId)
? personId
: (people.isNotEmpty ? people.first.id : null);
return GeneratedSignal(
title: itemMap['title'] as String? ?? 'Check in',
description: itemMap['description'] as String? ?? '',
type: itemMap['type'] as String? ?? 'recommendation',
personId: validPersonId,
);
}).toList();
} catch (e) {
return [];
}
}
}
LlmProviderException _mapProviderError(
LlmProvider provider,
DioException error,
) {
final int? statusCode = error.response?.statusCode;
final String providerName = _providerLabel(provider);
final String? providerMessage = _extractProviderMessage(error.response?.data);
if (statusCode == 429) {
final String detail = providerMessage == null ? '' : ' $providerMessage';
return LlmProviderException(
provider: provider,
statusCode: statusCode,
message:
'$providerName rejected the digest request with 429 rate limiting or quota pressure.$detail Check that the API key has available billing/quota, wait for the provider limit to reset, or switch to another configured provider.',
);
}
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.sendTimeout ||
error.type == DioExceptionType.receiveTimeout) {
return LlmProviderException(
provider: provider,
statusCode: statusCode,
message: '$providerName timed out while running the digest request.',
);
}
if (error.type == DioExceptionType.connectionError) {
return LlmProviderException(
provider: provider,
statusCode: statusCode,
message:
'Could not reach $providerName. Check the network connection and provider endpoint availability.',
);
}
final String statusText = statusCode == null ? '' : ' HTTP $statusCode.';
final String detail = providerMessage == null ? '' : ' $providerMessage';
return LlmProviderException(
provider: provider,
statusCode: statusCode,
message:
'$providerName could not complete the digest request.$statusText$detail',
);
}
String _diagnosticsDetails({
required LlmProvider provider,
required String baseUrl,
required String model,
required bool enableWebSearch,
required String systemPrompt,
required String userPrompt,
int? responseLength,
String? dioType,
int? statusCode,
String? providerMessage,
String? responseBody,
}) {
return const JsonEncoder.withIndent(' ').convert(
_diagnosticsMap(
provider: provider,
baseUrl: baseUrl,
model: model,
enableWebSearch: enableWebSearch,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
responseLength: responseLength,
dioType: dioType,
statusCode: statusCode,
providerMessage: providerMessage,
responseBody: responseBody,
),
);
}
Map<String, dynamic> _diagnosticsMap({
required LlmProvider provider,
required String baseUrl,
required String model,
required bool enableWebSearch,
required String systemPrompt,
required String userPrompt,
int? responseLength,
String? dioType,
int? statusCode,
String? providerMessage,
String? responseBody,
}) {
final Map<String, dynamic> diagnostics = <String, dynamic>{
'provider': provider.name,
'baseUrl': baseUrl,
'model': model,
'webSearchRequested': enableWebSearch,
'systemPromptChars': systemPrompt.length,
'userPromptChars': userPrompt.length,
};
if (responseLength != null) {
diagnostics['responseChars'] = responseLength;
}
if (dioType != null) {
diagnostics['dioType'] = dioType;
}
if (statusCode != null) {
diagnostics['statusCode'] = statusCode;
}
if (providerMessage != null) {
diagnostics['providerMessage'] = providerMessage;
}
if (responseBody != null) {
diagnostics['responseBody'] = responseBody;
}
return diagnostics;
}
String? _safeJson(Object? data) {
if (data == null) {
return null;
}
try {
final String encoded = data is String
? data
: const JsonEncoder.withIndent(' ').convert(data);
return _compactDiagnosticValue(encoded);
} catch (_) {
return _compactDiagnosticValue('$data');
}
}
String _compactDiagnosticValue(String value) {
final String compacted = value.trim().replaceAll(RegExp(r'\s+'), ' ');
if (compacted.length <= 3000) {
return compacted;
}
return '${compacted.substring(0, 2997)}...';
}
String _normalizeBaseUrl(String baseUrl) {
String normalized = baseUrl.trim();
while (normalized.endsWith('/')) {
normalized = normalized.substring(0, normalized.length - 1);
}
return normalized;
}
Dio _createDio({Duration receiveTimeout = const Duration(seconds: 20)}) {
return Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 20),
receiveTimeout: receiveTimeout,
sendTimeout: const Duration(seconds: 20),
),
);
}
List<LlmModelInfo> _sortModels(Iterable<LlmModelInfo> models) {
final List<LlmModelInfo> sorted = models.toList();
sorted.sort(
(LlmModelInfo a, LlmModelInfo b) =>
a.label.toLowerCase().compareTo(b.label.toLowerCase()),
);
return sorted;
}
String _providerLabel(LlmProvider provider) {
return provider.label;
}
String? _extractProviderMessage(Object? data) {
if (data is Map<String, dynamic>) {
final Object? direct = data['message'];
if (direct is String && direct.trim().isNotEmpty) {
return _compactProviderMessage(direct);
}
final Object? error = data['error'];
if (error is String && error.trim().isNotEmpty) {
return _compactProviderMessage(error);
}
if (error is Map<String, dynamic>) {
final Object? nested = error['message'];
if (nested is String && nested.trim().isNotEmpty) {
return _compactProviderMessage(nested);
}
}
}
return null;
}
String _compactProviderMessage(String value) {
final String compacted = value.trim().replaceAll(RegExp(r'\s+'), ' ');
if (compacted.length <= 240) {
return compacted;
}
return '${compacted.substring(0, 237)}...';
}
class GeneratedSignal {
const GeneratedSignal({
required this.title,
required this.description,
required this.type,
this.personId,
});
final String title;
final String description;
final String type;
final String? personId;
}
final llmServiceProvider = Provider<LlmService>((Ref ref) {
return LlmService(ref);
});
@@ -0,0 +1,261 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/llm/ollama/ollama_web_search_client.dart';
class OllamaToolChatClient {
OllamaToolChatClient({
required OllamaWebSearchClient webSearchClient,
Dio? dio,
int maxToolRounds = 3,
}) : _webSearchClient = webSearchClient,
_dio = dio ?? _createDio(),
_maxToolRounds = maxToolRounds;
final OllamaWebSearchClient _webSearchClient;
final Dio _dio;
final int _maxToolRounds;
Future<String> complete({
required String baseUrl,
required String model,
required String systemPrompt,
required String userPrompt,
}) async {
final List<Map<String, dynamic>> messages = <Map<String, dynamic>>[
{'role': 'system', 'content': _toolSystemPrompt(systemPrompt)},
{'role': 'user', 'content': userPrompt},
];
String latestContent = '';
for (int round = 0; round <= _maxToolRounds; round += 1) {
final Map<String, dynamic> message = await _chat(
baseUrl: baseUrl,
model: model,
messages: messages,
);
latestContent = _stringValue(message['content']);
final List<Map<String, dynamic>> toolCalls = _toolCalls(message);
if (toolCalls.isEmpty) {
return latestContent;
}
messages.add(<String, dynamic>{
'role': 'assistant',
'content': latestContent,
'tool_calls': toolCalls,
});
for (final Map<String, dynamic> toolCall in toolCalls) {
final String toolName = _toolName(toolCall);
final Map<String, dynamic> arguments = _toolArguments(toolCall);
final String toolResult = await _executeTool(toolName, arguments);
messages.add(<String, dynamic>{
'role': 'tool',
'tool_name': toolName,
'content': toolResult,
});
}
}
if (latestContent.trim().isNotEmpty) {
return latestContent;
}
throw StateError(
'Ollama did not return a final response after tool calls.',
);
}
Future<Map<String, dynamic>> _chat({
required String baseUrl,
required String model,
required List<Map<String, dynamic>> messages,
}) async {
final Response<Map<String, dynamic>> response = await _dio
.post<Map<String, dynamic>>(
'$baseUrl/api/chat',
options: Options(headers: {'Content-Type': 'application/json'}),
data: jsonEncode(<String, dynamic>{
'model': model,
'stream': false,
'messages': messages,
'tools': _tools,
}),
);
final Map<String, dynamic> data = response.data ?? <String, dynamic>{};
final Object? message = data['message'];
if (message is Map<String, dynamic>) {
return message;
}
return <String, dynamic>{};
}
Future<String> _executeTool(
String toolName,
Map<String, dynamic> arguments,
) async {
try {
switch (toolName) {
case 'search_web':
final String query = _stringValue(arguments['query']);
final int maxResults =
(arguments['maxResults'] as num?)?.toInt() ?? 5;
final List<OllamaWebSearchResult> results = await _webSearchClient
.search(query, maxResults: maxResults);
return jsonEncode(<String, dynamic>{
'query': query,
'results': results
.map((OllamaWebSearchResult result) => result.toJson())
.toList(growable: false),
});
case 'fetch_web_page':
final String url = _stringValue(arguments['url']);
final String text = await _webSearchClient.fetchPageText(url);
return jsonEncode(<String, String>{'url': url, 'text': text});
default:
return jsonEncode(<String, String>{
'error': 'Unknown tool: $toolName',
});
}
} catch (error) {
return jsonEncode(<String, String>{
'error': 'Tool $toolName failed: $error',
});
}
}
}
String _toolSystemPrompt(String systemPrompt) {
return '''
$systemPrompt
When you need current web information, call search_web first. Use fetch_web_page
only for URLs returned by search_web or URLs present in the user input. After
tool results are available, produce the final answer in the exact JSON schema
requested above. Do not expose tool-call internals in the final answer.
''';
}
List<Map<String, dynamic>> _toolCalls(Map<String, dynamic> message) {
final Object? toolCalls = message['tool_calls'];
if (toolCalls is! List<dynamic>) {
return const <Map<String, dynamic>>[];
}
return toolCalls
.whereType<Map<dynamic, dynamic>>()
.map(
(Map<dynamic, dynamic> item) =>
item.map((dynamic key, dynamic value) => MapEntry('$key', value)),
)
.toList(growable: false);
}
String _toolName(Map<String, dynamic> toolCall) {
final Object? function = toolCall['function'];
if (function is Map<String, dynamic>) {
return _stringValue(function['name']);
}
return _stringValue(toolCall['name']);
}
Map<String, dynamic> _toolArguments(Map<String, dynamic> toolCall) {
final Object? function = toolCall['function'];
final Object? rawArguments = function is Map<String, dynamic>
? function['arguments']
: toolCall['arguments'];
if (rawArguments is Map<String, dynamic>) {
return rawArguments;
}
if (rawArguments is Map<dynamic, dynamic>) {
return rawArguments.map(
(dynamic key, dynamic value) => MapEntry('$key', value),
);
}
if (rawArguments is String && rawArguments.trim().isNotEmpty) {
final Object? decoded = jsonDecode(rawArguments);
if (decoded is Map<String, dynamic>) {
return decoded;
}
if (decoded is Map<dynamic, dynamic>) {
return decoded.map(
(dynamic key, dynamic value) => MapEntry('$key', value),
);
}
}
return const <String, dynamic>{};
}
String _stringValue(Object? value) {
if (value is String) {
return value.trim();
}
return '';
}
List<Map<String, dynamic>> get _tools {
return <Map<String, dynamic>>[
<String, dynamic>{
'type': 'function',
'function': <String, dynamic>{
'name': 'search_web',
'description':
'Search the public web for current event, venue, date, price, '
'availability, and source information.',
'parameters': <String, dynamic>{
'type': 'object',
'properties': <String, dynamic>{
'query': <String, dynamic>{
'type': 'string',
'description': 'Specific search query.',
},
'maxResults': <String, dynamic>{
'type': 'integer',
'description': 'Maximum number of search results to return.',
'minimum': 1,
'maximum': 10,
},
},
'required': <String>['query'],
},
},
},
<String, dynamic>{
'type': 'function',
'function': <String, dynamic>{
'name': 'fetch_web_page',
'description':
'Fetch readable text from a specific web page when the search '
'snippet is not enough to verify event details.',
'parameters': <String, dynamic>{
'type': 'object',
'properties': <String, dynamic>{
'url': <String, dynamic>{
'type': 'string',
'description': 'HTTP or HTTPS URL to fetch.',
},
},
'required': <String>['url'],
},
},
},
];
}
Dio _createDio() {
return Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 20),
receiveTimeout: const Duration(minutes: 5),
sendTimeout: const Duration(seconds: 20),
),
);
}
final Provider<OllamaToolChatClient> ollamaToolChatClientProvider =
Provider<OllamaToolChatClient>((Ref ref) {
return OllamaToolChatClient(
webSearchClient: ref.watch(ollamaWebSearchClientProvider),
);
});
@@ -0,0 +1,173 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class OllamaWebSearchResult {
const OllamaWebSearchResult({
required this.title,
required this.url,
required this.snippet,
});
final String title;
final String url;
final String snippet;
Map<String, String> toJson() {
return <String, String>{'title': title, 'url': url, 'snippet': snippet};
}
}
abstract interface class OllamaWebSearchClient {
Future<List<OllamaWebSearchResult>> search(
String query, {
int maxResults = 5,
});
Future<String> fetchPageText(String url);
}
class DuckDuckGoOllamaWebSearchClient implements OllamaWebSearchClient {
DuckDuckGoOllamaWebSearchClient({Dio? dio}) : _dio = dio ?? _createDio();
final Dio _dio;
@override
Future<List<OllamaWebSearchResult>> search(
String query, {
int maxResults = 5,
}) async {
final String normalizedQuery = query.trim();
if (normalizedQuery.isEmpty) {
return const <OllamaWebSearchResult>[];
}
final Response<Map<String, dynamic>> response = await _dio
.get<Map<String, dynamic>>(
'https://api.duckduckgo.com/',
queryParameters: <String, Object>{
'q': normalizedQuery,
'format': 'json',
'no_html': 1,
'skip_disambig': 1,
},
);
final Map<String, dynamic> data = response.data ?? <String, dynamic>{};
final List<OllamaWebSearchResult> results = <OllamaWebSearchResult>[];
final String abstractUrl = _stringValue(data['AbstractURL']);
final String abstractText = _stringValue(data['AbstractText']);
final String heading = _stringValue(data['Heading']);
if (abstractUrl.isNotEmpty && abstractText.isNotEmpty) {
results.add(
OllamaWebSearchResult(
title: heading.isEmpty ? abstractUrl : heading,
url: abstractUrl,
snippet: abstractText,
),
);
}
_collectRelatedTopics(data['Results'], results);
_collectRelatedTopics(data['RelatedTopics'], results);
final Set<String> seenUrls = <String>{};
return results
.where((OllamaWebSearchResult result) => seenUrls.add(result.url))
.take(maxResults.clamp(1, 10))
.toList(growable: false);
}
@override
Future<String> fetchPageText(String url) async {
final Uri? uri = Uri.tryParse(url.trim());
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https')) {
return jsonEncode(<String, String>{'error': 'Invalid URL.'});
}
final Response<String> response = await _dio.get<String>(
uri.toString(),
options: Options(responseType: ResponseType.plain),
);
return _compactText(_htmlToText(response.data ?? ''));
}
static void _collectRelatedTopics(
Object? value,
List<OllamaWebSearchResult> results,
) {
if (value is! List<dynamic>) {
return;
}
for (final Object? item in value) {
if (item is! Map<String, dynamic>) {
continue;
}
final Object? nestedTopics = item['Topics'];
if (nestedTopics is List<dynamic>) {
_collectRelatedTopics(nestedTopics, results);
continue;
}
final String firstUrl = _stringValue(item['FirstURL']);
final String text = _stringValue(item['Text']);
if (firstUrl.isEmpty || text.isEmpty) {
continue;
}
results.add(
OllamaWebSearchResult(
title: text.split(' - ').first.trim(),
url: firstUrl,
snippet: text,
),
);
}
}
}
String _stringValue(Object? value) {
if (value is String) {
return value.trim();
}
return '';
}
String _htmlToText(String html) {
return html
.replaceAll(
RegExp(r'<script[\s\S]*?</script>', caseSensitive: false),
' ',
)
.replaceAll(RegExp(r'<style[\s\S]*?</style>', caseSensitive: false), ' ')
.replaceAll(RegExp(r'<[^>]+>'), ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'");
}
String _compactText(String value) {
final String compacted = value.trim().replaceAll(RegExp(r'\s+'), ' ');
if (compacted.length <= 4000) {
return compacted;
}
return '${compacted.substring(0, 3997)}...';
}
Dio _createDio() {
return Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 20),
receiveTimeout: const Duration(seconds: 20),
sendTimeout: const Duration(seconds: 20),
),
);
}
final Provider<OllamaWebSearchClient> ollamaWebSearchClientProvider =
Provider<OllamaWebSearchClient>((Ref ref) {
return DuckDuckGoOllamaWebSearchClient();
});
+4
View File
@@ -0,0 +1,4 @@
# Core Network
This folder owns shared networking infrastructure. It is the right place for
transport concerns like reachability and interceptors, not feature business logic.
+4
View File
@@ -0,0 +1,4 @@
# Network Interceptors
Request and response interception belongs here. Keep these classes stateless and
transport-focused so features do not need to know about client plumbing.
+4
View File
@@ -0,0 +1,4 @@
# Reachability
Network availability checks and platform-specific reachability code live here.
This folder should answer "can we talk to the network?" and nothing more.
@@ -30,19 +30,17 @@ class IoNetworkReachability implements NetworkReachability {
Stream<bool> watch({Duration timeout = const Duration(seconds: 2)}) {
final Stream<dynamic> changes =
_connectivity.onConnectivityChanged as Stream<dynamic>;
return changes
.asyncMap((dynamic event) async {
final Iterable<ConnectivityResult> results =
_normalizeConnectivityResults(event);
final bool hasTransport = results.any(
(ConnectivityResult result) => result != ConnectivityResult.none,
);
if (!hasTransport) {
return false;
}
return isReachable(timeout: timeout);
})
.distinct();
return changes.asyncMap((dynamic event) async {
final Iterable<ConnectivityResult> results =
_normalizeConnectivityResults(event);
final bool hasTransport = results.any(
(ConnectivityResult result) => result != ConnectivityResult.none,
);
if (!hasTransport) {
return false;
}
return isReachable(timeout: timeout);
}).distinct();
}
Iterable<ConnectivityResult> _normalizeConnectivityResults(dynamic event) {
+128
View File
@@ -0,0 +1,128 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
class SentryInit {
SentryInit();
Future<void>? _initializeFuture;
bool _initialized = false;
Future<void> initialize({FutureOr<void> Function()? appRunner}) async {
final Future<void>? inFlight = _initializeFuture;
if (inFlight != null) {
await inFlight;
if (appRunner != null && !_initialized) {
await Future<void>.sync(appRunner);
}
return;
}
_initializeFuture = _initializeInternal(appRunner: appRunner);
return _initializeFuture;
}
Future<void> _initializeInternal({
FutureOr<void> Function()? appRunner,
}) async {
if (AppConfig.sentryDsn.isEmpty) {
if (appRunner != null) {
await Future<void>.sync(appRunner);
}
return;
}
await SentryFlutter.init((options) {
options.dsn = AppConfig.sentryDsn;
options.environment = AppConfig.sentryEnvironment;
options.release = AppConfig.sentryRelease;
options.sendDefaultPii = false;
options.tracesSampleRate = 0.1;
}, appRunner: appRunner);
await Sentry.configureScope((scope) async {
await scope.setTag('service', 'relationship_saver');
await scope.setTag('build_mode', kReleaseMode ? 'release' : 'debug');
});
_installGlobalHandlers();
_initialized = true;
}
void captureException(
Object error, {
StackTrace? stackTrace,
String? area,
Map<String, dynamic>? context,
}) {
if (_initialized) {
unawaited(
Sentry.captureException(
error,
stackTrace: stackTrace,
withScope: (scope) async {
if (area != null) {
await scope.setTag('area', area);
}
if (context != null) {
await scope.setContexts('diagnostics', context);
}
},
),
);
}
}
void captureMessage(
String message, {
SentryLevel level = SentryLevel.info,
String? area,
Map<String, dynamic>? context,
}) {
if (_initialized) {
unawaited(
Sentry.captureMessage(
message,
level: level,
withScope: (scope) async {
if (area != null) {
await scope.setTag('area', area);
}
if (context != null) {
await scope.setContexts('diagnostics', context);
}
},
),
);
}
}
void _installGlobalHandlers() {
final FlutterExceptionHandler? previousFlutterHandler =
FlutterError.onError;
FlutterError.onError = (FlutterErrorDetails details) {
previousFlutterHandler?.call(details);
captureException(
details.exception,
stackTrace: details.stack,
area: 'flutter_framework',
context: <String, dynamic>{
'library': details.library,
'context': details.context?.toDescription(),
},
);
};
final ErrorCallback? previousPlatformHandler =
PlatformDispatcher.instance.onError;
PlatformDispatcher.instance.onError = (Object error, StackTrace stack) {
captureException(error, stackTrace: stack, area: 'platform_dispatcher');
return previousPlatformHandler?.call(error, stack) ?? false;
};
}
}
final sentryInitProvider = Provider<SentryInit>((Ref ref) {
return SentryInit();
});
+7
View File
@@ -0,0 +1,7 @@
# Core Presentation
This folder holds shared UI primitives that are not owned by one product
feature. Keep widgets here small and reusable.
`frosted_card.dart` is the canonical shared card surface used across several
screens.
+40
View File
@@ -0,0 +1,40 @@
import 'dart:ui';
import 'package:flutter/material.dart';
class FrostedCard extends StatelessWidget {
const FrostedCard({
required this.child,
super.key,
this.padding = const EdgeInsets.all(20),
});
final Widget child;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(24),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: Container(
padding: padding,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Colors.white.withValues(alpha: 0.6)),
boxShadow: const <BoxShadow>[
BoxShadow(
color: Color(0x14000000),
blurRadius: 24,
offset: Offset(0, 12),
),
],
),
child: child,
),
),
);
}
}
+4
View File
@@ -0,0 +1,4 @@
# Core Utils
Use this folder sparingly for tiny reusable helpers that are truly cross-cutting.
If a helper is owned by one slice, keep it in that slice instead.
+13
View File
@@ -0,0 +1,13 @@
# Feature Slices
Each feature folder is the main place to work on product behavior.
Typical subfolders:
- `domain/`: models or invariants owned by the slice
- `application/`: orchestration, providers, controllers, side effects
- `presentation/`: screens, widgets, dialogs, UI state
- `data/`: persistence or repositories owned by the slice
Some older files at the slice root still exist as compatibility exports so
tests and untouched code paths keep working while the structure settles.
@@ -0,0 +1,105 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
const String aiDigestNotificationPayload = 'ai_digest_review_inbox';
final Provider<AiDigestNotificationIntentBus>
aiDigestNotificationIntentBusProvider = Provider<AiDigestNotificationIntentBus>(
(Ref ref) {
final AiDigestNotificationIntentBus bus = AiDigestNotificationIntentBus();
ref.onDispose(() {
unawaited(bus.close());
});
return bus;
},
);
class AiDigestNotificationIntentBus {
final StreamController<String> _controller =
StreamController<String>.broadcast();
Stream<String> get intents => _controller.stream;
void emitTap(String payload) {
if (!_controller.isClosed) {
_controller.add(payload);
}
}
Future<void> close() => _controller.close();
}
abstract interface class AiDigestNotifier {
Future<void> showDigestReady(int count);
}
class LocalAiDigestNotifier implements AiDigestNotifier {
LocalAiDigestNotifier({
FlutterLocalNotificationsPlugin? plugin,
void Function(String payload)? onTapPayload,
}) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(),
_onTapPayload = onTapPayload;
final FlutterLocalNotificationsPlugin _plugin;
final void Function(String payload)? _onTapPayload;
Future<void>? _initializeFuture;
@override
Future<void> showDigestReady(int count) async {
if (!_supportsNotifications()) {
return;
}
await _ensureInitialized();
await _plugin.show(
id: 8124,
title: 'Relationship digest ready',
body:
'$count private ${count == 1 ? 'suggestion is' : 'suggestions are'} ready to review.',
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
'relationship_saver_ai_digest',
'Relationship Digest',
channelDescription: 'Private AI digest suggestions ready for review',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
),
iOS: DarwinNotificationDetails(),
macOS: DarwinNotificationDetails(),
),
payload: aiDigestNotificationPayload,
);
}
Future<void> _ensureInitialized() {
return _initializeFuture ??= _plugin.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(),
macOS: DarwinInitializationSettings(),
),
onDidReceiveNotificationResponse: (NotificationResponse response) {
final String? payload = response.payload;
if (payload != null && payload.isNotEmpty) {
_onTapPayload?.call(payload);
}
},
);
}
bool _supportsNotifications() {
if (kIsWeb) {
return false;
}
return defaultTargetPlatform != TargetPlatform.linux;
}
}
final Provider<AiDigestNotifier> aiDigestNotifierProvider =
Provider<AiDigestNotifier>((Ref ref) {
return LocalAiDigestNotifier(
onTapPayload: ref.watch(aiDigestNotificationIntentBusProvider).emitTap,
);
});
@@ -0,0 +1,227 @@
import 'dart:convert';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:uuid/uuid.dart';
const Uuid _uuid = Uuid();
class AiDigestParseResult {
const AiDigestParseResult({required this.drafts, this.error});
final List<AiSuggestionDraft> drafts;
final String? error;
bool get success => error == null;
}
class AiDigestResponseParser {
const AiDigestResponseParser({
this.maxItems = 10,
this.maxTitleChars = 90,
this.maxDetailsChars = 420,
});
final int maxItems;
final int maxTitleChars;
final int maxDetailsChars;
AiDigestParseResult parse({
required String response,
required Map<String, String> tokenToPersonId,
required String sourceRunId,
required DateTime suggestedAt,
}) {
try {
final Object? decoded = jsonDecode(_extractJson(response));
final List<dynamic> items = _extractItems(decoded);
final List<AiSuggestionDraft> drafts = <AiSuggestionDraft>[];
for (final dynamic item in items.take(maxItems)) {
if (item is! Map<String, dynamic>) {
continue;
}
final String? token = _string(item['personToken']);
final String? personId = token == null ? null : tokenToPersonId[token];
if (personId == null) {
continue;
}
final AiSuggestionKind? kind = _kind(item['kind']);
final String title = _trimAndCap(
_string(item['title']) ?? '',
maxTitleChars,
);
if (kind == null || title.isEmpty) {
continue;
}
drafts.add(
AiSuggestionDraft(
id: 'ai-${_uuid.v4()}',
personId: personId,
kind: kind,
title: title,
details: _trimAndCap(
_string(item['details']) ?? '',
maxDetailsChars,
),
suggestedAt: suggestedAt,
suggestedFor:
_dateOrNull(item['suggestedFor']) ??
_dateOrNull(item['suggestedTiming']),
eventEndsAt:
_dateOrNull(item['eventEndsAt']) ??
_dateOrNull(item['endsAt']) ??
_dateOrNull(item['endTime']),
eventLocation: _optionalTrimAndCap(
_string(item['eventLocation']) ?? _string(item['location']),
160,
),
eventUrl: _urlOrNull(item['eventUrl']) ?? _urlOrNull(item['url']),
sourceUrls: _sourceUrls(item['sourceUrls'] ?? item['sources']),
confidence: _confidence(item['confidence']),
status: AiSuggestionStatus.pending,
sourceRunId: sourceRunId,
reason: _trimAndCap(_string(item['reason']) ?? '', 240),
),
);
}
if (drafts.isEmpty) {
return const AiDigestParseResult(
drafts: <AiSuggestionDraft>[],
error: 'No valid digest suggestions returned.',
);
}
return AiDigestParseResult(drafts: drafts);
} catch (error) {
return AiDigestParseResult(
drafts: const <AiSuggestionDraft>[],
error: 'Unable to parse digest response: $error',
);
}
}
String _extractJson(String response) {
final String trimmed = response.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
return trimmed;
}
final int objectStart = trimmed.indexOf('{');
final int arrayStart = trimmed.indexOf('[');
final int start = objectStart == -1
? arrayStart
: arrayStart == -1
? objectStart
: objectStart < arrayStart
? objectStart
: arrayStart;
final int objectEnd = trimmed.lastIndexOf('}');
final int arrayEnd = trimmed.lastIndexOf(']');
final int end = objectEnd > arrayEnd ? objectEnd : arrayEnd;
if (start < 0 || end < start) {
throw const FormatException('No JSON object or array found.');
}
return trimmed.substring(start, end + 1);
}
List<dynamic> _extractItems(Object? decoded) {
if (decoded is List<dynamic>) {
return decoded;
}
if (decoded is Map<String, dynamic>) {
final Object? suggestions = decoded['suggestions'] ?? decoded['items'];
if (suggestions is List<dynamic>) {
return suggestions;
}
}
throw const FormatException('Digest response must be a JSON array.');
}
AiSuggestionKind? _kind(Object? raw) {
final String? value = _string(raw);
if (value == null) {
return null;
}
for (final AiSuggestionKind kind in AiSuggestionKind.values) {
if (kind.name == value) {
return kind;
}
}
return null;
}
String? _string(Object? raw) {
if (raw == null) {
return null;
}
final String value = '$raw'.trim();
return value.isEmpty ? null : value;
}
DateTime? _dateOrNull(Object? raw) {
final String? value = _string(raw);
if (value == null) {
return null;
}
return DateTime.tryParse(value)?.toLocal();
}
String? _urlOrNull(Object? raw) {
final String? value = _string(raw);
if (value == null) {
return null;
}
final Uri? uri = Uri.tryParse(value);
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
return null;
}
return uri.toString();
}
List<String> _sourceUrls(Object? raw) {
final List<Object?> values = raw is List<dynamic> ? raw : <Object?>[raw];
final Set<String> urls = <String>{};
for (final Object? value in values) {
if (value is Map<String, dynamic>) {
final String? url =
_urlOrNull(value['url']) ??
_urlOrNull(value['uri']) ??
_urlOrNull(value['link']);
if (url != null) {
urls.add(url);
}
continue;
}
final String? url = _urlOrNull(value);
if (url != null) {
urls.add(url);
}
}
return urls.take(5).toList(growable: false);
}
double _confidence(Object? raw) {
if (raw is num) {
return raw.toDouble().clamp(0.0, 1.0).toDouble();
}
return 0.5;
}
String _trimAndCap(String value, int maxChars) {
final String trimmed = value.trim();
if (trimmed.length <= maxChars) {
return trimmed;
}
return trimmed.substring(0, maxChars).trim();
}
String? _optionalTrimAndCap(String? value, int maxChars) {
if (value == null) {
return null;
}
final String trimmed = _trimAndCap(value, maxChars);
return trimmed.isEmpty ? null : trimmed;
}
}
@@ -0,0 +1,449 @@
import 'dart:convert';
import 'dart:math' as math;
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
class AnonymizedLlmDigestContext {
const AnonymizedLlmDigestContext({
required this.payload,
required this.tokenToPersonId,
});
final Map<String, dynamic> payload;
final Map<String, String> tokenToPersonId;
String toPromptJson() {
return const JsonEncoder.withIndent(' ').convert(payload);
}
}
class AnonymizedLlmContextBuilder {
const AnonymizedLlmContextBuilder({
this.maxPeople = 20,
this.maxSignalsPerPerson = 8,
this.maxFactsPerPerson = 8,
this.maxPriorSuggestionsPerPerson = 6,
DateTime Function()? now,
}) : _now = now ?? DateTime.now;
final int maxPeople;
final int maxSignalsPerPerson;
final int maxFactsPerPerson;
final int maxPriorSuggestionsPerPerson;
final DateTime Function() _now;
AnonymizedLlmDigestContext build(LocalDataState state) {
final DateTime now = _now();
final List<PersonProfile> selectedPeople =
state.people
.where(
(PersonProfile person) => _isDigestRelevant(state, person, now),
)
.toList(growable: false)
..sort(
(PersonProfile a, PersonProfile b) => _urgencyScore(
state,
b,
now,
).compareTo(_urgencyScore(state, a, now)),
);
final List<Map<String, dynamic>> peoplePayload = <Map<String, dynamic>>[];
final Map<String, String> tokenToPersonId = <String, String>{};
for (int i = 0; i < math.min(selectedPeople.length, maxPeople); i += 1) {
final PersonProfile person = selectedPeople[i];
final String token = 'person_${(i + 1).toString().padLeft(3, '0')}';
tokenToPersonId[token] = person.id;
peoplePayload.add(_personPayload(state, person, token, now));
}
return AnonymizedLlmDigestContext(
tokenToPersonId: tokenToPersonId,
payload: <String, dynamic>{
'schema': 'relationship_saver_private_digest_v1',
'task':
'Create a balanced private weekly digest with gift ideas, event ideas, reminders, and check-ins.',
'rules': <String>[
'Use only personToken values from the input.',
'Do not infer or ask for names.',
'Return JSON only.',
'Prefer practical suggestions that can be reviewed before saving.',
'Use web search for current event recommendations and include source URLs.',
'Only suggest real upcoming events with verified date and venue details.',
'Do not repeat priorSuggestions for the same personToken.',
'When shared_message_datetime is present, treat it as the point-in-time reference for that evidence.',
'Do not turn old health/status evidence into a current check-in; preserve durable preferences instead.',
],
'limits': <String, dynamic>{
'maxItems': 10,
'allowedKinds': <String>[
'giftIdea',
'eventIdea',
'checkIn',
'reminder',
],
},
'people': peoplePayload,
},
);
}
bool _isDigestRelevant(
LocalDataState state,
PersonProfile person,
DateTime now,
) {
if (_daysUntil(person.nextMoment, now).abs() <= 60) {
return true;
}
final DateTime? last = person.lastInteractedAt;
if (last == null || now.difference(last).inDays >= 14) {
return true;
}
return state.importantDates.any(
(PersonImportantDate value) =>
value.personId == person.id &&
!value.isSensitive &&
_daysUntil(value.date, now).abs() <= 60,
) ||
state.preferenceSignals.any(
(PersonPreferenceSignal signal) =>
signal.personId == person.id &&
signal.status != PreferenceSignalStatus.dismissed,
);
}
int _urgencyScore(LocalDataState state, PersonProfile person, DateTime now) {
final int nextMomentDays = _daysUntil(person.nextMoment, now).abs();
int score = math.max(0, 80 - nextMomentDays);
final DateTime? last = person.lastInteractedAt;
if (last == null) {
score += 30;
} else {
score += math.min(40, now.difference(last).inDays);
}
score +=
state.importantDates
.where(
(PersonImportantDate value) =>
value.personId == person.id &&
!value.isSensitive &&
_daysUntil(value.date, now).abs() <= 60,
)
.length *
10;
return score;
}
Map<String, dynamic> _personPayload(
LocalDataState state,
PersonProfile person,
String token,
DateTime now,
) {
final List<PersonPreferenceSignal> signals = state.preferenceSignals
.where(
(PersonPreferenceSignal signal) =>
signal.personId == person.id &&
signal.status != PreferenceSignalStatus.dismissed,
)
.take(maxSignalsPerPerson)
.toList(growable: false);
final List<PersonImportantDate> dates = state.importantDates
.where(
(PersonImportantDate value) =>
value.personId == person.id && !value.isSensitive,
)
.take(maxSignalsPerPerson)
.toList(growable: false);
return <String, dynamic>{
'personToken': token,
'relationshipCategory': _relationshipCategory(person.relationship),
'affinityBand': _affinityBand(person.affinityScore),
'upcoming': <String>[
_relativeWindow('next planned moment', person.nextMoment, now),
...dates.map(
(PersonImportantDate value) => _relativeWindow(
_safeCategory(value.classification),
value.date,
now,
),
),
],
'recency': _recency(person.lastInteractedAt, now),
'interests': _safeList(<String>[
...person.tags,
...signals
.where(
(PersonPreferenceSignal signal) =>
signal.polarity == PreferenceSignalPolarity.like,
)
.map((PersonPreferenceSignal signal) => signal.label),
]).take(maxSignalsPerPerson).toList(growable: false),
'constraints': _safeList(
signals
.where(
(PersonPreferenceSignal signal) =>
signal.polarity == PreferenceSignalPolarity.dislike,
)
.map((PersonPreferenceSignal signal) => 'avoid ${signal.label}')
.toList(growable: false),
).take(maxSignalsPerPerson).toList(growable: false),
'preferenceSignals': _preferenceSignals(signals, now),
'capturedFacts': _capturedFacts(state, person, now),
'priorSuggestions': _priorSuggestions(state, person),
};
}
List<Map<String, String>> _capturedFacts(
LocalDataState state,
PersonProfile person,
DateTime now,
) {
final List<String> identityTerms = _identityTerms(state);
final Map<String, DateTime> sharedMessageTimes = _sharedMessageTimes(state);
return state.personFacts
.where(
(PersonFact fact) =>
fact.personId == person.id &&
!fact.isSensitive &&
!fact.needsReview &&
fact.text.trim().isNotEmpty,
)
.take(maxFactsPerPerson)
.map((PersonFact fact) {
final DateTime? sharedMessageDateTime = fact.sharedMessageId == null
? null
: sharedMessageTimes[fact.sharedMessageId];
return <String, String>{
'type': _factTypeLabel(fact.type),
if (_safeContextText(fact.label, identityTerms).isNotEmpty)
'label': _safeContextText(fact.label, identityTerms),
'summary': _safeContextText(fact.text, identityTerms),
if (sharedMessageDateTime != null) ...<String, String>{
'shared_message_datetime': sharedMessageDateTime
.toUtc()
.toIso8601String(),
'sourceAge': _ageLabel(sharedMessageDateTime, now),
},
};
})
.where(
(Map<String, String> fact) =>
fact['summary'] != null && fact['summary']!.isNotEmpty,
)
.toList(growable: false);
}
List<Map<String, String>> _preferenceSignals(
List<PersonPreferenceSignal> signals,
DateTime now,
) {
return signals
.map(
(PersonPreferenceSignal signal) => <String, String>{
'label': _safeCategory(signal.label),
'category': _safeCategory(signal.category),
'polarity': signal.polarity == PreferenceSignalPolarity.dislike
? 'dislike'
: 'like',
'lastObserved': signal.lastSeenAt.toUtc().toIso8601String(),
'sourceAge': _ageLabel(signal.lastSeenAt, now),
},
)
.where((Map<String, String> signal) => signal['label']!.isNotEmpty)
.take(maxSignalsPerPerson)
.toList(growable: false);
}
Map<String, DateTime> _sharedMessageTimes(LocalDataState state) {
return <String, DateTime>{
for (final SharedMessageEntry entry in state.sharedMessages)
if (entry.sharedMessageDateTime != null)
entry.id: entry.sharedMessageDateTime!,
};
}
List<Map<String, String>> _priorSuggestions(
LocalDataState state,
PersonProfile person,
) {
return state.aiSuggestionDrafts
.where((AiSuggestionDraft draft) => draft.personId == person.id)
.take(maxPriorSuggestionsPerPerson)
.map(
(AiSuggestionDraft draft) => <String, String>{
'kind': draft.kind.name,
'status': draft.status.name,
'title': _safeCategory(draft.title),
},
)
.toList(growable: false);
}
String _relationshipCategory(String value) {
final String normalized = value.toLowerCase();
if (_containsAny(normalized, <String>[
'partner',
'spouse',
'wife',
'husband',
])) {
return 'partner';
}
if (_containsAny(normalized, <String>[
'family',
'sister',
'brother',
'mother',
'father',
'parent',
'cousin',
'aunt',
'uncle',
])) {
return 'family';
}
if (normalized.contains('friend')) {
return 'friend';
}
if (_containsAny(normalized, <String>['work', 'colleague', 'coworker'])) {
return 'colleague';
}
return 'relationship';
}
String _affinityBand(int score) {
if (score >= 85) {
return 'very close';
}
if (score >= 65) {
return 'close';
}
return 'light';
}
String _relativeWindow(String label, DateTime date, DateTime now) {
final int days = _daysUntil(date, now);
final String when = days == 0
? 'today'
: days > 0
? 'in about $days days'
: 'about ${days.abs()} days ago';
return '${_safeCategory(label)} $when';
}
String _recency(DateTime? lastInteractedAt, DateTime now) {
if (lastInteractedAt == null) {
return 'no recent interaction recorded';
}
final int days = now.difference(lastInteractedAt).inDays;
if (days <= 1) {
return 'contacted recently';
}
return 'last contact about $days days ago';
}
String _ageLabel(DateTime value, DateTime now) {
final int days = now.difference(value).inDays;
if (days <= 1) {
return 'current or recent';
}
if (days < 45) {
return 'about $days days ago';
}
final int months = (days / 30).round().clamp(2, 24).toInt();
return 'about $months months ago';
}
List<String> _safeList(Iterable<String> values) {
final List<String> out = <String>[];
final Set<String> seen = <String>{};
for (final String raw in values) {
final String value = _safeCategory(raw);
if (value.isEmpty) {
continue;
}
if (seen.add(value.toLowerCase())) {
out.add(value);
}
}
return out;
}
String _safeCategory(String value) {
return value
.trim()
.replaceAll(RegExp(r'https?://\S+'), '')
.replaceAll(RegExp(r'[^a-zA-Z0-9 +&/-]+'), '')
.replaceAll(RegExp(r'\s+'), ' ')
.trim()
.toLowerCase();
}
String _safeContextText(String? value, List<String> identityTerms) {
if (value == null) {
return '';
}
String safe = value.trim().replaceAll(RegExp(r'https?://\S+'), '');
for (final String term in identityTerms) {
safe = safe.replaceAll(
RegExp(RegExp.escape(term), caseSensitive: false),
'this person',
);
}
return safe
.replaceAll(RegExp(r'[\r\n\t]+'), ' ')
.replaceAll(RegExp(r'[^a-zA-Z0-9 .,;:!?+&%$#@()\-/]+'), '')
.replaceAll(RegExp(r'\s+'), ' ')
.trim()
.toLowerCase();
}
List<String> _identityTerms(LocalDataState state) {
final Set<String> terms = <String>{};
for (final PersonProfile person in state.people) {
for (final String raw in <String>[person.name, ...person.aliases]) {
final String term = raw.trim();
if (term.length >= 3) {
terms.add(term);
}
for (final String part in term.split(RegExp(r'\s+'))) {
if (part.length >= 3) {
terms.add(part);
}
}
}
}
final List<String> sorted = terms.toList(growable: false)
..sort((String a, String b) => b.length.compareTo(a.length));
return sorted;
}
String _factTypeLabel(CapturedFactType type) {
return switch (type) {
CapturedFactType.note => 'note',
CapturedFactType.like => 'like',
CapturedFactType.dislike => 'dislike',
CapturedFactType.importantDate => 'important date',
CapturedFactType.giftIdea => 'gift idea',
CapturedFactType.placeIdea => 'place idea',
CapturedFactType.activityIdea => 'activity idea',
CapturedFactType.misc => 'misc',
};
}
int _daysUntil(DateTime date, DateTime now) {
return date.difference(DateTime(now.year, now.month, now.day)).inDays;
}
bool _containsAny(String value, List<String> probes) {
return probes.any(value.contains);
}
}
@@ -0,0 +1,53 @@
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:url_launcher/url_launcher.dart';
class CalendarEventLauncher {
const CalendarEventLauncher();
Future<bool> addSuggestionToCalendar(AiSuggestionDraft draft) async {
final Uri? uri = calendarUriForSuggestion(draft);
if (uri == null) {
return false;
}
if (await launchUrl(uri, mode: LaunchMode.externalApplication)) {
return true;
}
return launchUrl(uri);
}
}
Uri? calendarUriForSuggestion(AiSuggestionDraft draft) {
final DateTime? startsAt = draft.suggestedFor;
if (draft.kind != AiSuggestionKind.eventIdea || startsAt == null) {
return null;
}
final DateTime endsAt =
draft.eventEndsAt ?? startsAt.add(const Duration(hours: 2));
final String details = <String>[
draft.details,
if (draft.reason != null && draft.reason!.trim().isNotEmpty)
'Why: ${draft.reason!.trim()}',
if (draft.eventUrl != null && draft.eventUrl!.trim().isNotEmpty)
draft.eventUrl!.trim(),
if (draft.sourceUrls.isNotEmpty) 'Sources: ${draft.sourceUrls.join(', ')}',
].where((String value) => value.trim().isNotEmpty).join('\n\n');
return Uri.https('calendar.google.com', '/calendar/render', <String, String>{
'action': 'TEMPLATE',
'text': draft.title,
'dates': '${_calendarTimestamp(startsAt)}/${_calendarTimestamp(endsAt)}',
if (details.isNotEmpty) 'details': details,
if (draft.eventLocation != null && draft.eventLocation!.trim().isNotEmpty)
'location': draft.eventLocation!.trim(),
});
}
String _calendarTimestamp(DateTime value) {
final DateTime utc = value.toUtc();
return '${utc.year.toString().padLeft(4, '0')}'
'${utc.month.toString().padLeft(2, '0')}'
'${utc.day.toString().padLeft(2, '0')}T'
'${utc.hour.toString().padLeft(2, '0')}'
'${utc.minute.toString().padLeft(2, '0')}'
'${utc.second.toString().padLeft(2, '0')}Z';
}
@@ -0,0 +1,125 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/ai_digest/data/llm_digest_config.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:workmanager/workmanager.dart';
const String llmDigestTaskUniqueName = 'relationship_saver_llm_digest_weekly';
const String llmDigestTaskName = 'com.relationshipsaver.llm.digest';
abstract interface class LlmDigestBackgroundScheduler {
Future<void> configure(LlmDigestConfigState config);
Future<void> cancel();
}
class WorkmanagerLlmDigestBackgroundScheduler
implements LlmDigestBackgroundScheduler {
const WorkmanagerLlmDigestBackgroundScheduler({Workmanager? workmanager})
: _workmanager = workmanager;
final Workmanager? _workmanager;
Workmanager get _driver => _workmanager ?? Workmanager();
@override
Future<void> configure(LlmDigestConfigState config) async {
if (!config.enabled) {
await cancel();
return;
}
try {
await _driver.registerPeriodicTask(
llmDigestTaskUniqueName,
llmDigestTaskName,
frequency: const Duration(days: 7),
initialDelay: _initialDelay(config, DateTime.now()),
constraints: Constraints(
networkType: config.requireWifi
? NetworkType.unmetered
: NetworkType.connected,
requiresCharging: config.requireCharging,
),
existingWorkPolicy: ExistingPeriodicWorkPolicy.update,
);
} on UnimplementedError {
// Flutter tests and unsupported platforms have no Workmanager backend.
}
}
@override
Future<void> cancel() async {
try {
await _driver.cancelByUniqueName(llmDigestTaskUniqueName);
} on UnimplementedError {
// Flutter tests and unsupported platforms have no Workmanager backend.
}
}
Duration _initialDelay(LlmDigestConfigState config, DateTime now) {
DateTime target = DateTime(
now.year,
now.month,
now.day,
config.preferredHour,
);
final int dayDelta = (config.preferredWeekday - now.weekday) % 7;
target = target.add(Duration(days: dayDelta));
if (!target.isAfter(now)) {
target = target.add(const Duration(days: 7));
}
return target.difference(now);
}
}
final Provider<LlmDigestBackgroundScheduler>
llmDigestBackgroundSchedulerProvider = Provider<LlmDigestBackgroundScheduler>(
(Ref ref) => const WorkmanagerLlmDigestBackgroundScheduler(),
);
class LlmDigestBackgroundRunner extends ConsumerStatefulWidget {
const LlmDigestBackgroundRunner({required this.child, super.key});
final Widget child;
@override
ConsumerState<LlmDigestBackgroundRunner> createState() =>
_LlmDigestBackgroundRunnerState();
}
class _LlmDigestBackgroundRunnerState
extends ConsumerState<LlmDigestBackgroundRunner> {
ProviderSubscription<LlmDigestConfigState>? _subscription;
@override
void initState() {
super.initState();
unawaited(_initializeAndConfigure());
_subscription = ref.listenManual<LlmDigestConfigState>(
llmDigestConfigProvider,
(LlmDigestConfigState? previous, LlmDigestConfigState next) {
unawaited(
ref.read(llmDigestBackgroundSchedulerProvider).configure(next),
);
},
);
}
Future<void> _initializeAndConfigure() async {
await ref.read(llmDigestConfigProvider.notifier).initialize();
await ref
.read(llmDigestBackgroundSchedulerProvider)
.configure(ref.read(llmDigestConfigProvider));
}
@override
void dispose() {
_subscription?.close();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}
@@ -0,0 +1,40 @@
import 'package:battery_plus/battery_plus.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
abstract interface class LlmDigestEnvironment {
Future<bool> canRun(LlmDigestConfigState config);
}
class DeviceLlmDigestEnvironment implements LlmDigestEnvironment {
DeviceLlmDigestEnvironment({Connectivity? connectivity, Battery? battery})
: _connectivity = connectivity ?? Connectivity(),
_battery = battery ?? Battery();
final Connectivity _connectivity;
final Battery _battery;
@override
Future<bool> canRun(LlmDigestConfigState config) async {
if (config.requireWifi) {
final List<ConnectivityResult> results = await _connectivity
.checkConnectivity();
if (!results.contains(ConnectivityResult.wifi)) {
return false;
}
}
if (config.requireCharging) {
final BatteryState state = await _battery.batteryState;
if (state != BatteryState.charging && state != BatteryState.full) {
return false;
}
}
return true;
}
}
final Provider<LlmDigestEnvironment> llmDigestEnvironmentProvider =
Provider<LlmDigestEnvironment>((Ref ref) => DeviceLlmDigestEnvironment());
@@ -0,0 +1,353 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/app/data/relationship_repository.dart';
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/core/llm/llm_config.dart';
import 'package:relationship_saver/core/llm/llm_service.dart';
import 'package:relationship_saver/core/observability/sentry_init.dart';
import 'package:relationship_saver/features/ai_digest/application/ai_digest_notifier.dart';
import 'package:relationship_saver/features/ai_digest/application/ai_digest_response_parser.dart';
import 'package:relationship_saver/features/ai_digest/application/anonymized_llm_context_builder.dart';
import 'package:relationship_saver/features/ai_digest/application/llm_digest_environment.dart';
import 'package:relationship_saver/features/ai_digest/application/llm_digest_text_client.dart';
import 'package:relationship_saver/features/ai_digest/data/llm_digest_config.dart';
import 'package:relationship_saver/features/ai_digest/data/llm_digest_run_store.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:uuid/uuid.dart';
const Duration _scheduledCooldown = Duration(days: 7);
const Duration _staleRunningAfter = Duration(minutes: 30);
const Uuid _uuid = Uuid();
final Provider<bool> llmDigestApiKeyConfiguredProvider = Provider<bool>(
(Ref ref) => ref.watch(llmConfigProvider).isConfigured,
);
final Provider<Future<void> Function()> llmDigestInitializerProvider =
Provider<Future<void> Function()>((Ref ref) {
return () async {
await ref.read(llmConfigProvider.notifier).initialize();
await ref.read(llmDigestConfigProvider.notifier).initialize();
};
});
class LlmDigestRunResult {
const LlmDigestRunResult({
required this.started,
required this.completed,
this.createdDraftCount = 0,
this.reason,
});
final bool started;
final bool completed;
final int createdDraftCount;
final String? reason;
}
class LlmDigestOrchestrator {
LlmDigestOrchestrator(this._ref, {DateTime Function()? now})
: _now = now ?? DateTime.now;
final Ref _ref;
final DateTime Function() _now;
Future<LlmDigestRunResult> runScheduledDigest() {
return _run(bypassCooldown: false, notify: true);
}
Future<LlmDigestRunResult> runManualDigest() {
return _run(bypassCooldown: true, notify: true);
}
Future<LlmDigestRunResult> _run({
required bool bypassCooldown,
required bool notify,
}) async {
await _ref.read(llmDigestInitializerProvider)();
final LlmDigestConfigState config = _ref.read(llmDigestConfigProvider);
if (!config.enabled && !bypassCooldown) {
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'Digest is disabled.',
);
}
if (!_ref.read(llmDigestApiKeyConfiguredProvider)) {
await _markFailure('No LLM API key configured.');
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'No LLM API key configured.',
);
}
final LlmDigestRunStore store = _ref.read(llmDigestRunStoreProvider);
final LlmDigestRunState before = await store.read();
final DateTime now = _now();
if (_isAlreadyRunning(before, now)) {
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'Digest is already running.',
);
}
if (!bypassCooldown && !_cooldownAllowsRun(before, now)) {
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'Weekly digest cooldown is still active.',
);
}
if (!bypassCooldown && !_failureBackoffAllowsRun(before, now)) {
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'Failure backoff is still active.',
);
}
if (!bypassCooldown &&
!await _ref.read(llmDigestEnvironmentProvider).canRun(config)) {
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'Network or charging policy is not satisfied.',
);
}
await store.write(
before.copyWith(lastStartedAt: now, running: true, lastFailure: null),
);
try {
final LocalDataState state = await _ref.read(
localRepositoryProvider.future,
);
final AnonymizedLlmDigestContext context =
const AnonymizedLlmContextBuilder().build(state);
if (context.tokenToPersonId.isEmpty) {
await store.write(
before.copyWith(
lastStartedAt: now,
lastCompletedAt: now,
running: false,
lastFailure: null,
consecutiveFailures: 0,
),
);
return const LlmDigestRunResult(
started: true,
completed: true,
reason: 'No eligible people for digest.',
);
}
final String sourceRunId = 'digest-${_uuid.v4()}';
final String response = await _completeDigestPrompt(
userPrompt: context.toPromptJson(),
enableWebSearch: config.enableWebSearch,
sourceRunId: sourceRunId,
);
final AiDigestParseResult parsed = const AiDigestResponseParser().parse(
response: response,
tokenToPersonId: context.tokenToPersonId,
sourceRunId: sourceRunId,
suggestedAt: now,
);
if (!parsed.success) {
_ref
.read(sentryInitProvider)
.captureMessage(
parsed.error ?? 'Invalid digest response.',
level: SentryLevel.warning,
area: 'llm_digest_parse',
context: <String, dynamic>{
'sourceRunId': sourceRunId,
'responseChars': response.length,
'eligiblePeople': context.tokenToPersonId.length,
},
);
await _markFailure(parsed.error ?? 'Invalid digest response.');
return LlmDigestRunResult(
started: true,
completed: false,
reason: parsed.error,
);
}
await _ref
.read(localRepositoryProvider.notifier)
.saveAiSuggestionDrafts(parsed.drafts);
await store.write(
LlmDigestRunState(
lastStartedAt: now,
lastCompletedAt: _now(),
consecutiveFailures: 0,
),
);
if (notify && parsed.drafts.isNotEmpty) {
await _ref
.read(aiDigestNotifierProvider)
.showDigestReady(parsed.drafts.length);
}
return LlmDigestRunResult(
started: true,
completed: true,
createdDraftCount: parsed.drafts.length,
);
} catch (error, stackTrace) {
final String reason = _digestFailureReason(error);
_ref
.read(sentryInitProvider)
.captureException(
error,
stackTrace: stackTrace,
area: 'llm_digest',
context: <String, dynamic>{
'bypassCooldown': bypassCooldown,
'notify': notify,
},
);
await _markFailure(reason);
return LlmDigestRunResult(
started: true,
completed: false,
reason: reason,
);
}
}
Future<String> _completeDigestPrompt({
required String userPrompt,
required bool enableWebSearch,
required String sourceRunId,
}) async {
try {
return await _ref
.read(llmDigestTextClientProvider)
.complete(
systemPrompt: _systemPrompt(enableWebSearch: enableWebSearch),
userPrompt: userPrompt,
enableWebSearch: enableWebSearch,
);
} on LlmProviderException catch (error) {
if (!enableWebSearch || error.statusCode != 429) {
rethrow;
}
_ref
.read(sentryInitProvider)
.captureMessage(
'Grounded digest request hit provider quota; retrying without web search.',
level: SentryLevel.warning,
area: 'llm_digest_grounding_fallback',
context: <String, dynamic>{
'sourceRunId': sourceRunId,
'provider': error.provider.name,
'statusCode': error.statusCode,
},
);
return _ref
.read(llmDigestTextClientProvider)
.complete(
systemPrompt: _systemPrompt(enableWebSearch: false),
userPrompt: userPrompt,
enableWebSearch: false,
);
}
}
Future<void> _markFailure(String reason) async {
final LlmDigestRunStore store = _ref.read(llmDigestRunStoreProvider);
final LlmDigestRunState current = await store.read();
await store.write(
LlmDigestRunState(
lastStartedAt: current.lastStartedAt,
lastCompletedAt: current.lastCompletedAt,
lastFailure: reason,
consecutiveFailures: current.consecutiveFailures + 1,
),
);
}
bool _isAlreadyRunning(LlmDigestRunState state, DateTime now) {
final DateTime? started = state.lastStartedAt;
return state.running &&
started != null &&
now.difference(started) < _staleRunningAfter;
}
bool _cooldownAllowsRun(LlmDigestRunState state, DateTime now) {
final DateTime? completed = state.lastCompletedAt;
return completed == null || now.difference(completed) >= _scheduledCooldown;
}
bool _failureBackoffAllowsRun(LlmDigestRunState state, DateTime now) {
if (state.consecutiveFailures <= 0) {
return true;
}
final DateTime? started = state.lastStartedAt;
if (started == null) {
return true;
}
final int multiplier = 1 << state.consecutiveFailures.clamp(0, 5).toInt();
return now.difference(started) >= Duration(hours: multiplier);
}
}
String _digestFailureReason(Object error) {
if (error is LlmProviderException) {
return error.message;
}
return '$error';
}
String _systemPrompt({required bool enableWebSearch}) {
final String groundingRules = enableWebSearch
? '''
Use web search for current event, venue, availability, price, and timing claims.
Prefer official event or venue pages over aggregators. Only classify an item as
eventIdea when there is a real upcoming event with a concrete date in the next
60 days. Include sourceUrls for grounded suggestions and do not fabricate URLs.
'''
: '''
Web search grounding is disabled or unavailable for this run. Do not make
current availability, price, or venue claims. Avoid eventIdea suggestions unless
the input already contains enough concrete event details and leave sourceUrls
empty when no source URL was provided.
''';
return '''
You create private relationship-maintenance digest suggestions.
The input contains pseudonymous people only. Never ask for or invent names.
Return exactly one JSON object:
{
"suggestions": [
{
"personToken": "person_001",
"kind": "giftIdea" | "eventIdea" | "checkIn" | "reminder",
"title": "short action title",
"details": "practical details for the user to review",
"suggestedTiming": "optional ISO-8601 start date/time or null",
"eventEndsAt": "optional ISO-8601 end date/time for eventIdea only, otherwise null",
"eventLocation": "optional venue or city for eventIdea only, otherwise null",
"eventUrl": "optional official event URL for eventIdea only, otherwise null",
"sourceUrls": ["0-3 source URLs used to verify the suggestion"],
"confidence": 0.0,
"reason": "brief non-sensitive rationale"
}
]
}
Return at most 10 suggestions. Use only personToken values present in input.
If an input fact includes shared_message_datetime, use that timestamp as the
evidence date. A message from months ago about temporary health, travel, mood,
or availability should not produce an immediate checkIn unless other recent
evidence supports it; keep durable preferences, constraints, and gift ideas.
$groundingRules
''';
}
final Provider<LlmDigestOrchestrator> llmDigestOrchestratorProvider =
Provider<LlmDigestOrchestrator>((Ref ref) => LlmDigestOrchestrator(ref));
@@ -0,0 +1,34 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/llm/llm_service.dart';
abstract interface class LlmDigestTextClient {
Future<String> complete({
required String systemPrompt,
required String userPrompt,
bool enableWebSearch = false,
});
}
class ConfiguredLlmDigestTextClient implements LlmDigestTextClient {
const ConfiguredLlmDigestTextClient(this._service);
final LlmService _service;
@override
Future<String> complete({
required String systemPrompt,
required String userPrompt,
bool enableWebSearch = false,
}) {
return _service.completeText(
systemPrompt: systemPrompt,
userPrompt: userPrompt,
enableWebSearch: enableWebSearch,
);
}
}
final Provider<LlmDigestTextClient> llmDigestTextClientProvider =
Provider<LlmDigestTextClient>((Ref ref) {
return ConfiguredLlmDigestTextClient(ref.watch(llmServiceProvider));
});
@@ -0,0 +1,85 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:shared_preferences/shared_preferences.dart';
const String _enabledKey = 'llm_digest_enabled';
const String _preferredWeekdayKey = 'llm_digest_preferred_weekday';
const String _preferredHourKey = 'llm_digest_preferred_hour';
const String _requireWifiKey = 'llm_digest_require_wifi';
const String _requireChargingKey = 'llm_digest_require_charging';
const String _enableWebSearchKey = 'llm_digest_enable_web_search';
class LlmDigestConfigNotifier extends Notifier<LlmDigestConfigState> {
Future<void>? _initializeFuture;
@override
LlmDigestConfigState build() {
return const LlmDigestConfigState();
}
Future<void> initialize() {
_initializeFuture ??= _initializeFromStorage();
return _initializeFuture!;
}
Future<void> _initializeFromStorage() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
state = LlmDigestConfigState(
enabled: prefs.getBool(_enabledKey) ?? false,
preferredWeekday: prefs.getInt(_preferredWeekdayKey) ?? DateTime.sunday,
preferredHour: prefs.getInt(_preferredHourKey) ?? 21,
requireWifi: prefs.getBool(_requireWifiKey) ?? true,
requireCharging: prefs.getBool(_requireChargingKey) ?? true,
enableWebSearch: prefs.getBool(_enableWebSearchKey) ?? true,
);
}
Future<void> setEnabled(bool enabled) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setBool(_enabledKey, enabled);
state = state.copyWith(enabled: enabled);
}
Future<void> setPreferredRunWindow({
required int weekday,
required int hour,
}) async {
final int normalizedWeekday = weekday.clamp(
DateTime.monday,
DateTime.sunday,
);
final int normalizedHour = hour.clamp(0, 23);
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setInt(_preferredWeekdayKey, normalizedWeekday);
await prefs.setInt(_preferredHourKey, normalizedHour);
state = state.copyWith(
preferredWeekday: normalizedWeekday,
preferredHour: normalizedHour,
);
}
Future<void> setNetworkPolicy({
required bool requireWifi,
required bool requireCharging,
}) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setBool(_requireWifiKey, requireWifi);
await prefs.setBool(_requireChargingKey, requireCharging);
state = state.copyWith(
requireWifi: requireWifi,
requireCharging: requireCharging,
);
}
Future<void> setWebSearchEnabled(bool enabled) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setBool(_enableWebSearchKey, enabled);
state = state.copyWith(enableWebSearch: enabled);
}
}
final NotifierProvider<LlmDigestConfigNotifier, LlmDigestConfigState>
llmDigestConfigProvider =
NotifierProvider<LlmDigestConfigNotifier, LlmDigestConfigState>(
LlmDigestConfigNotifier.new,
);
@@ -0,0 +1,34 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:shared_preferences/shared_preferences.dart';
const String _runStateKey = 'llm_digest_run_state';
class LlmDigestRunStore {
const LlmDigestRunStore();
Future<LlmDigestRunState> read() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
final String? raw = prefs.getString(_runStateKey);
if (raw == null || raw.isEmpty) {
return const LlmDigestRunState();
}
try {
return LlmDigestRunState.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
} on FormatException {
return const LlmDigestRunState();
}
}
Future<void> write(LlmDigestRunState state) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setString(_runStateKey, jsonEncode(state.toJson()));
}
}
final Provider<LlmDigestRunStore> llmDigestRunStoreProvider =
Provider<LlmDigestRunStore>((Ref ref) => const LlmDigestRunStore());
@@ -0,0 +1,239 @@
// ignore_for_file: sort_constructors_first
import 'package:flutter/foundation.dart';
enum AiSuggestionKind { giftIdea, eventIdea, checkIn, reminder }
enum AiSuggestionStatus { pending, accepted, dismissed }
enum LlmDigestCadence { weekly }
@immutable
class AiSuggestionDraft {
const AiSuggestionDraft({
required this.id,
required this.personId,
required this.kind,
required this.title,
required this.details,
required this.suggestedAt,
required this.confidence,
required this.status,
required this.sourceRunId,
this.suggestedFor,
this.eventEndsAt,
this.eventLocation,
this.eventUrl,
this.sourceUrls = const <String>[],
this.reason,
});
final String id;
final String personId;
final AiSuggestionKind kind;
final String title;
final String details;
final DateTime suggestedAt;
final DateTime? suggestedFor;
final DateTime? eventEndsAt;
final String? eventLocation;
final String? eventUrl;
final List<String> sourceUrls;
final double confidence;
final AiSuggestionStatus status;
final String sourceRunId;
final String? reason;
AiSuggestionDraft copyWith({
String? id,
String? personId,
AiSuggestionKind? kind,
String? title,
String? details,
DateTime? suggestedAt,
DateTime? suggestedFor,
DateTime? eventEndsAt,
String? eventLocation,
String? eventUrl,
List<String>? sourceUrls,
double? confidence,
AiSuggestionStatus? status,
String? sourceRunId,
String? reason,
}) {
return AiSuggestionDraft(
id: id ?? this.id,
personId: personId ?? this.personId,
kind: kind ?? this.kind,
title: title ?? this.title,
details: details ?? this.details,
suggestedAt: suggestedAt ?? this.suggestedAt,
suggestedFor: suggestedFor ?? this.suggestedFor,
eventEndsAt: eventEndsAt ?? this.eventEndsAt,
eventLocation: eventLocation ?? this.eventLocation,
eventUrl: eventUrl ?? this.eventUrl,
sourceUrls: sourceUrls ?? this.sourceUrls,
confidence: confidence ?? this.confidence,
status: status ?? this.status,
sourceRunId: sourceRunId ?? this.sourceRunId,
reason: reason ?? this.reason,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'personId': personId,
'kind': kind.name,
'title': title,
'details': details,
'suggestedAt': suggestedAt.toUtc().toIso8601String(),
'suggestedFor': suggestedFor?.toUtc().toIso8601String(),
'eventEndsAt': eventEndsAt?.toUtc().toIso8601String(),
'eventLocation': eventLocation,
'eventUrl': eventUrl,
'sourceUrls': sourceUrls,
'confidence': confidence,
'status': status.name,
'sourceRunId': sourceRunId,
'reason': reason,
};
}
factory AiSuggestionDraft.fromJson(Map<String, dynamic> json) {
final String kindName =
json['kind'] as String? ?? AiSuggestionKind.checkIn.name;
final String statusName =
json['status'] as String? ?? AiSuggestionStatus.pending.name;
return AiSuggestionDraft(
id: json['id'] as String,
personId: json['personId'] as String,
kind: AiSuggestionKind.values.firstWhere(
(AiSuggestionKind value) => value.name == kindName,
orElse: () => AiSuggestionKind.checkIn,
),
title: json['title'] as String? ?? 'Suggestion',
details: json['details'] as String? ?? '',
suggestedAt: DateTime.parse(
json['suggestedAt'] as String? ??
DateTime.now().toUtc().toIso8601String(),
).toLocal(),
suggestedFor: json['suggestedFor'] == null
? null
: DateTime.parse(json['suggestedFor'] as String).toLocal(),
eventEndsAt: json['eventEndsAt'] == null
? null
: DateTime.parse(json['eventEndsAt'] as String).toLocal(),
eventLocation: json['eventLocation'] as String?,
eventUrl: json['eventUrl'] as String?,
sourceUrls: (json['sourceUrls'] as List<dynamic>? ?? <dynamic>[])
.map((dynamic value) => '$value')
.toList(growable: false),
confidence: (json['confidence'] as num?)?.toDouble() ?? 0,
status: AiSuggestionStatus.values.firstWhere(
(AiSuggestionStatus value) => value.name == statusName,
orElse: () => AiSuggestionStatus.pending,
),
sourceRunId: json['sourceRunId'] as String? ?? 'unknown-run',
reason: json['reason'] as String?,
);
}
}
@immutable
class LlmDigestRunState {
const LlmDigestRunState({
this.lastStartedAt,
this.lastCompletedAt,
this.lastFailure,
this.consecutiveFailures = 0,
this.running = false,
});
final DateTime? lastStartedAt;
final DateTime? lastCompletedAt;
final String? lastFailure;
final int consecutiveFailures;
final bool running;
LlmDigestRunState copyWith({
DateTime? lastStartedAt,
DateTime? lastCompletedAt,
String? lastFailure,
int? consecutiveFailures,
bool? running,
}) {
return LlmDigestRunState(
lastStartedAt: lastStartedAt ?? this.lastStartedAt,
lastCompletedAt: lastCompletedAt ?? this.lastCompletedAt,
lastFailure: lastFailure,
consecutiveFailures: consecutiveFailures ?? this.consecutiveFailures,
running: running ?? this.running,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'lastStartedAt': lastStartedAt?.toUtc().toIso8601String(),
'lastCompletedAt': lastCompletedAt?.toUtc().toIso8601String(),
'lastFailure': lastFailure,
'consecutiveFailures': consecutiveFailures,
'running': running,
};
}
factory LlmDigestRunState.fromJson(Map<String, dynamic> json) {
return LlmDigestRunState(
lastStartedAt: json['lastStartedAt'] == null
? null
: DateTime.parse(json['lastStartedAt'] as String).toLocal(),
lastCompletedAt: json['lastCompletedAt'] == null
? null
: DateTime.parse(json['lastCompletedAt'] as String).toLocal(),
lastFailure: json['lastFailure'] as String?,
consecutiveFailures: (json['consecutiveFailures'] as num?)?.toInt() ?? 0,
running: json['running'] as bool? ?? false,
);
}
}
@immutable
class LlmDigestConfigState {
const LlmDigestConfigState({
this.enabled = false,
this.cadence = LlmDigestCadence.weekly,
this.preferredWeekday = DateTime.sunday,
this.preferredHour = 21,
this.requireWifi = true,
this.requireCharging = true,
this.enableWebSearch = true,
});
final bool enabled;
final LlmDigestCadence cadence;
final int preferredWeekday;
final int preferredHour;
final bool requireWifi;
final bool requireCharging;
final bool enableWebSearch;
LlmDigestConfigState copyWith({
bool? enabled,
LlmDigestCadence? cadence,
int? preferredWeekday,
int? preferredHour,
bool? requireWifi,
bool? requireCharging,
bool? enableWebSearch,
}) {
return LlmDigestConfigState(
enabled: enabled ?? this.enabled,
cadence: cadence ?? this.cadence,
preferredWeekday: preferredWeekday ?? this.preferredWeekday,
preferredHour: preferredHour ?? this.preferredHour,
requireWifi: requireWifi ?? this.requireWifi,
requireCharging: requireCharging ?? this.requireCharging,
enableWebSearch: enableWebSearch ?? this.enableWebSearch,
);
}
}
@@ -0,0 +1,50 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/ai_digest/application/ai_digest_notifier.dart';
import 'package:relationship_saver/features/ai_digest/presentation/ai_digest_review_view.dart';
class AiDigestNotificationListener extends ConsumerStatefulWidget {
const AiDigestNotificationListener({required this.child, super.key});
final Widget child;
@override
ConsumerState<AiDigestNotificationListener> createState() =>
_AiDigestNotificationListenerState();
}
class _AiDigestNotificationListenerState
extends ConsumerState<AiDigestNotificationListener> {
StreamSubscription<String>? _subscription;
@override
void initState() {
super.initState();
_subscription = ref
.read(aiDigestNotificationIntentBusProvider)
.intents
.listen(_handleIntent);
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
Future<void> _handleIntent(String payload) async {
if (!mounted || payload != aiDigestNotificationPayload) {
return;
}
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) => const AiDigestReviewView(),
),
);
}
}
@@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/app/data/relationship_repository.dart';
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/ai_digest/application/calendar_event_launcher.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
class AiDigestReviewView extends ConsumerWidget {
const AiDigestReviewView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<LocalDataState> localData = ref.watch(
localRepositoryProvider,
);
return Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(title: const Text('AI Review Inbox')),
body: localData.when(
data: (LocalDataState data) => _AiDigestReviewContent(data: data),
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) =>
Center(child: Text('Unable to load AI suggestions. $error')),
),
);
}
}
class _AiDigestReviewContent extends ConsumerWidget {
const _AiDigestReviewContent({required this.data});
final LocalDataState data;
@override
Widget build(BuildContext context, WidgetRef ref) {
final List<AiSuggestionDraft> pending = data.aiSuggestionDrafts
.where(
(AiSuggestionDraft draft) =>
draft.status == AiSuggestionStatus.pending,
)
.toList(growable: false);
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
for (final PersonProfile person in data.people) person.id: person,
};
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 760;
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
compact ? 16 : 28,
compact ? 16 : 24,
compact ? 16 : 28,
28,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Private suggestions',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Review each suggestion before it changes local data.',
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 16),
if (pending.isEmpty)
const FrostedCard(child: Text('No pending AI suggestions.'))
else
...pending.map(
(AiSuggestionDraft draft) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _AiSuggestionCard(
draft: draft,
person: peopleById[draft.personId],
onAccept: () => ref
.read(localRepositoryProvider.notifier)
.acceptAiSuggestionDraft(draft.id),
onDismiss: () => ref
.read(localRepositoryProvider.notifier)
.dismissAiSuggestionDraft(draft.id),
),
),
),
],
),
);
},
);
}
}
class _AiSuggestionCard extends StatelessWidget {
const _AiSuggestionCard({
required this.draft,
required this.person,
required this.onAccept,
required this.onDismiss,
});
final AiSuggestionDraft draft;
final PersonProfile? person;
final VoidCallback onAccept;
final VoidCallback onDismiss;
@override
Widget build(BuildContext context) {
return FrostedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Icon(_iconForKind(draft.kind), color: AppTheme.primary),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
draft.title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
'${_kindLabel(draft.kind)} · ${person?.name ?? 'Unknown person'}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
],
),
if (draft.details.isNotEmpty) ...<Widget>[
const SizedBox(height: 12),
Text(draft.details),
],
if (draft.reason != null &&
draft.reason!.trim().isNotEmpty) ...<Widget>[
const SizedBox(height: 8),
Text(
'Why: ${draft.reason}',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
),
],
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
FilledButton.icon(
onPressed: person == null ? null : onAccept,
icon: const Icon(Icons.check_rounded),
label: const Text('Accept'),
),
if (draft.kind == AiSuggestionKind.eventIdea &&
draft.suggestedFor != null)
OutlinedButton.icon(
onPressed: () => _addToCalendar(context),
icon: const Icon(Icons.calendar_month_rounded),
label: const Text('Calendar'),
),
OutlinedButton.icon(
onPressed: onDismiss,
icon: const Icon(Icons.close_rounded),
label: const Text('Dismiss'),
),
],
),
],
),
);
}
Future<void> _addToCalendar(BuildContext context) async {
final bool launched = await const CalendarEventLauncher()
.addSuggestionToCalendar(draft);
if (!context.mounted || launched) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Could not open calendar.')));
}
IconData _iconForKind(AiSuggestionKind kind) {
return switch (kind) {
AiSuggestionKind.giftIdea => Icons.card_giftcard_rounded,
AiSuggestionKind.eventIdea => Icons.event_available_rounded,
AiSuggestionKind.checkIn => Icons.chat_bubble_outline_rounded,
AiSuggestionKind.reminder => Icons.notifications_active_outlined,
};
}
String _kindLabel(AiSuggestionKind kind) {
return switch (kind) {
AiSuggestionKind.giftIdea => 'Gift idea',
AiSuggestionKind.eventIdea => 'Event idea',
AiSuggestionKind.checkIn => 'Check-in',
AiSuggestionKind.reminder => 'Reminder',
};
}
}
+9
View File
@@ -0,0 +1,9 @@
# Auth Slice
This slice owns sign-in UI and session lifecycle.
- `application/session_controller.dart`: async auth session state.
- `presentation/sign_in_view.dart`: sign-in form and environment status UI.
Touch this slice when you change auth UX, local session behavior, or backend
sign-in integration.
+4
View File
@@ -0,0 +1,4 @@
# Auth Application
This layer coordinates auth state and session lifecycle. Start here when you
need to change how the app decides whether a user is signed in.
@@ -0,0 +1,76 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/auth/token_store.dart';
import 'package:relationship_saver/integrations/backend/backend_gateway.dart';
import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
/// Manages authenticated session lifecycle for app-level navigation.
class SessionController extends AsyncNotifier<AuthSession?> {
@override
Future<AuthSession?> build() async {
return ref.read(tokenStoreProvider).read();
}
Future<void> signIn({
required SignInMethod method,
String? email,
String? password,
String? idToken,
}) async {
state = const AsyncLoading<AuthSession?>();
try {
final BackendGateway gateway = ref.read(backendGatewayProvider);
final AuthSession session = await gateway.signIn(
SignInRequest(
method: method,
email: email,
password: password,
idToken: idToken,
),
);
// Ensure fake gateway sessions are also persisted.
await ref.read(tokenStoreProvider).write(session);
state = AsyncData<AuthSession?>(session);
} catch (error, stackTrace) {
state = AsyncError<AuthSession?>(error, stackTrace);
}
}
Future<void> signOut() async {
final BackendGateway gateway = ref.read(backendGatewayProvider);
final TokenStore tokenStore = ref.read(tokenStoreProvider);
try {
await gateway.signOut();
} catch (_) {
// Offline-first sign-out should always clear local session.
}
await tokenStore.clear();
state = const AsyncData<AuthSession?>(null);
}
Future<void> refreshProfile() async {
final AuthSession? current = state.asData?.value;
if (current == null) {
return;
}
try {
final UserProfile profile = await ref.read(backendGatewayProvider).me();
final AuthSession updated = current.copyWith(user: profile);
await ref.read(tokenStoreProvider).write(updated);
state = AsyncData<AuthSession?>(updated);
} catch (_) {
// Keep current state if profile refresh fails.
}
}
}
final AsyncNotifierProvider<SessionController, AuthSession?>
sessionControllerProvider =
AsyncNotifierProvider<SessionController, AuthSession?>(
SessionController.new,
);
+4
View File
@@ -0,0 +1,4 @@
# Auth Presentation
Auth UI belongs here. Keep sign-in screens, auth-specific widgets, and small
view helpers in this folder rather than mixing them into app bootstrap code.
@@ -0,0 +1,228 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/auth/session_controller.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
class SignInView extends ConsumerStatefulWidget {
const SignInView({super.key});
@override
ConsumerState<SignInView> createState() => _SignInViewState();
}
class _SignInViewState extends ConsumerState<SignInView> {
SignInMethod _method = SignInMethod.password;
final TextEditingController _emailController = TextEditingController(
text: 'demo@example.com',
);
final TextEditingController _passwordController = TextEditingController(
text: 'password123',
);
final TextEditingController _idTokenController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_idTokenController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final AsyncValue<AuthSession?> session = ref.watch(
sessionControllerProvider,
);
final bool busy = session.isLoading;
final Object? error = session.asError?.error;
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints viewport) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: viewport.maxHeight - 32),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 620),
child: FrostedCard(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 480;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Sign in to continue',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(
'Your local data remains available offline. Backend unlocks sync and signals.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 18),
if (compact)
DropdownButtonFormField<SignInMethod>(
initialValue: _method,
items: SignInMethod.values
.map(
(SignInMethod method) =>
DropdownMenuItem<SignInMethod>(
value: method,
child: Text(_labelForMethod(method)),
),
)
.toList(growable: false),
onChanged: busy
? null
: (SignInMethod? value) {
if (value == null) {
return;
}
setState(() {
_method = value;
});
},
decoration: const InputDecoration(
labelText: 'Method',
),
)
else
SegmentedButton<SignInMethod>(
segments: const <ButtonSegment<SignInMethod>>[
ButtonSegment<SignInMethod>(
value: SignInMethod.password,
label: Text('Password'),
),
ButtonSegment<SignInMethod>(
value: SignInMethod.emailMagicLink,
label: Text('Magic Link'),
),
ButtonSegment<SignInMethod>(
value: SignInMethod.oidc,
label: Text('OIDC'),
),
],
selected: <SignInMethod>{_method},
onSelectionChanged: busy
? null
: (Set<SignInMethod> value) {
setState(() {
_method = value.first;
});
},
),
const SizedBox(height: 14),
if (_method == SignInMethod.password ||
_method == SignInMethod.emailMagicLink)
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
enabled: !busy,
decoration: const InputDecoration(
labelText: 'Email',
),
),
if (_method == SignInMethod.password)
TextField(
controller: _passwordController,
obscureText: true,
enabled: !busy,
decoration: const InputDecoration(
labelText: 'Password',
),
),
if (_method == SignInMethod.oidc)
TextField(
controller: _idTokenController,
enabled: !busy,
decoration: const InputDecoration(
labelText: 'ID Token',
),
),
if (error != null)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
'Sign-in failed: $error',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: const Color(0xFFC83030)),
),
),
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
FilledButton.icon(
onPressed: busy ? null : _submit,
icon: busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.login_rounded),
label: const Text('Sign In'),
),
Text(
AppConfig.useFakeBackend
? 'Fake mode enabled'
: 'REST mode (${AppConfig.backendBaseUrl})',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: AppTheme.textSecondary),
),
],
),
],
);
},
),
),
),
),
),
);
},
);
}
Future<void> _submit() async {
final String? email = _emailController.text.trim().isEmpty
? null
: _emailController.text.trim();
final String? password = _passwordController.text.trim().isEmpty
? null
: _passwordController.text.trim();
final String? idToken = _idTokenController.text.trim().isEmpty
? null
: _idTokenController.text.trim();
await ref
.read(sessionControllerProvider.notifier)
.signIn(
method: _method,
email: email,
password: password,
idToken: idToken,
);
}
String _labelForMethod(SignInMethod method) {
return switch (method) {
SignInMethod.password => 'Password',
SignInMethod.emailMagicLink => 'Magic Link',
SignInMethod.oidc => 'OIDC',
};
}
}
+2 -76
View File
@@ -1,76 +1,2 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/auth/token_store.dart';
import 'package:relationship_saver/integrations/backend/backend_gateway.dart';
import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
/// Manages authenticated session lifecycle for app-level navigation.
class SessionController extends AsyncNotifier<AuthSession?> {
@override
Future<AuthSession?> build() async {
return ref.read(tokenStoreProvider).read();
}
Future<void> signIn({
required SignInMethod method,
String? email,
String? password,
String? idToken,
}) async {
state = const AsyncLoading<AuthSession?>();
try {
final BackendGateway gateway = ref.read(backendGatewayProvider);
final AuthSession session = await gateway.signIn(
SignInRequest(
method: method,
email: email,
password: password,
idToken: idToken,
),
);
// Ensure fake gateway sessions are also persisted.
await ref.read(tokenStoreProvider).write(session);
state = AsyncData<AuthSession?>(session);
} catch (error, stackTrace) {
state = AsyncError<AuthSession?>(error, stackTrace);
}
}
Future<void> signOut() async {
final BackendGateway gateway = ref.read(backendGatewayProvider);
final TokenStore tokenStore = ref.read(tokenStoreProvider);
try {
await gateway.signOut();
} catch (_) {
// Offline-first sign-out should always clear local session.
}
await tokenStore.clear();
state = const AsyncData<AuthSession?>(null);
}
Future<void> refreshProfile() async {
final AuthSession? current = state.asData?.value;
if (current == null) {
return;
}
try {
final UserProfile profile = await ref.read(backendGatewayProvider).me();
final AuthSession updated = current.copyWith(user: profile);
await ref.read(tokenStoreProvider).write(updated);
state = AsyncData<AuthSession?>(updated);
} catch (_) {
// Keep current state if profile refresh fails.
}
}
}
final AsyncNotifierProvider<SessionController, AuthSession?>
sessionControllerProvider =
AsyncNotifierProvider<SessionController, AuthSession?>(
SessionController.new,
);
// Legacy compatibility export. Prefer the `application` file in this slice.
export 'package:relationship_saver/features/auth/application/session_controller.dart';
+2 -228
View File
@@ -1,228 +1,2 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/auth/session_controller.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
class SignInView extends ConsumerStatefulWidget {
const SignInView({super.key});
@override
ConsumerState<SignInView> createState() => _SignInViewState();
}
class _SignInViewState extends ConsumerState<SignInView> {
SignInMethod _method = SignInMethod.password;
final TextEditingController _emailController = TextEditingController(
text: 'demo@example.com',
);
final TextEditingController _passwordController = TextEditingController(
text: 'password123',
);
final TextEditingController _idTokenController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_idTokenController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final AsyncValue<AuthSession?> session = ref.watch(
sessionControllerProvider,
);
final bool busy = session.isLoading;
final Object? error = session.asError?.error;
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints viewport) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: viewport.maxHeight - 32),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 620),
child: FrostedCard(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 480;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Sign in to continue',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(
'Your local data remains available offline. Backend unlocks sync and signals.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 18),
if (compact)
DropdownButtonFormField<SignInMethod>(
initialValue: _method,
items: SignInMethod.values
.map(
(SignInMethod method) =>
DropdownMenuItem<SignInMethod>(
value: method,
child: Text(_labelForMethod(method)),
),
)
.toList(growable: false),
onChanged: busy
? null
: (SignInMethod? value) {
if (value == null) {
return;
}
setState(() {
_method = value;
});
},
decoration: const InputDecoration(
labelText: 'Method',
),
)
else
SegmentedButton<SignInMethod>(
segments: const <ButtonSegment<SignInMethod>>[
ButtonSegment<SignInMethod>(
value: SignInMethod.password,
label: Text('Password'),
),
ButtonSegment<SignInMethod>(
value: SignInMethod.emailMagicLink,
label: Text('Magic Link'),
),
ButtonSegment<SignInMethod>(
value: SignInMethod.oidc,
label: Text('OIDC'),
),
],
selected: <SignInMethod>{_method},
onSelectionChanged: busy
? null
: (Set<SignInMethod> value) {
setState(() {
_method = value.first;
});
},
),
const SizedBox(height: 14),
if (_method == SignInMethod.password ||
_method == SignInMethod.emailMagicLink)
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
enabled: !busy,
decoration: const InputDecoration(
labelText: 'Email',
),
),
if (_method == SignInMethod.password)
TextField(
controller: _passwordController,
obscureText: true,
enabled: !busy,
decoration: const InputDecoration(
labelText: 'Password',
),
),
if (_method == SignInMethod.oidc)
TextField(
controller: _idTokenController,
enabled: !busy,
decoration: const InputDecoration(
labelText: 'ID Token',
),
),
if (error != null)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
'Sign-in failed: $error',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: const Color(0xFFC83030)),
),
),
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
FilledButton.icon(
onPressed: busy ? null : _submit,
icon: busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.login_rounded),
label: const Text('Sign In'),
),
Text(
AppConfig.useFakeBackend
? 'Fake mode enabled'
: 'REST mode (${AppConfig.backendBaseUrl})',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: AppTheme.textSecondary),
),
],
),
],
);
},
),
),
),
),
),
);
},
);
}
Future<void> _submit() async {
final String? email = _emailController.text.trim().isEmpty
? null
: _emailController.text.trim();
final String? password = _passwordController.text.trim().isEmpty
? null
: _passwordController.text.trim();
final String? idToken = _idTokenController.text.trim().isEmpty
? null
: _idTokenController.text.trim();
await ref
.read(sessionControllerProvider.notifier)
.signIn(
method: _method,
email: email,
password: password,
idToken: idToken,
);
}
String _labelForMethod(SignInMethod method) {
return switch (method) {
SignInMethod.password => 'Password',
SignInMethod.emailMagicLink => 'Magic Link',
SignInMethod.oidc => 'OIDC',
};
}
}
// Legacy compatibility export. Prefer the `presentation` file in this slice.
export 'package:relationship_saver/features/auth/presentation/sign_in_view.dart';
+9
View File
@@ -0,0 +1,9 @@
# Dashboard Slice
This slice owns the home overview experience.
- `domain/dashboard_models.dart`: lightweight dashboard task and summary types.
- `presentation/dashboard_view.dart`: dashboard screen and interactions.
If you want to change the founder daily workflow, this is usually the first
screen to inspect after the app shell.
File diff suppressed because it is too large Load Diff

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