Compare commits
10 Commits
e3151e71d0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d80486b25e | |||
| 5d80af375e | |||
| 9d2da0c600 | |||
| 0bfe67b1bf | |||
| e13d8b76cd | |||
| d81215db73 | |||
| 42a59e959f | |||
| f655adfbea | |||
| dab50abf0e | |||
| 6c4a139448 |
@@ -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/
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
# Relationship Saver Progress Log
|
||||
|
||||
Updated: 2026-02-22
|
||||
Updated: 2026-02-23
|
||||
|
||||
## Collaboration Rule (Carry Forward)
|
||||
|
||||
|
||||
+2
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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]';
|
||||
}
|
||||
@@ -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(' ', ' ')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'");
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user