Refine backendless share intake

This commit is contained in:
Rijad Zuzo
2026-05-18 20:33:54 +02:00
parent f655adfbea
commit 42a59e959f
37 changed files with 1467 additions and 824 deletions
+8
View File
@@ -40,6 +40,7 @@ app.*.map.json
# Android # Android
# ----------------------------- # -----------------------------
android/.gradle/ android/.gradle/
android/local.properties
android/app/debug/ android/app/debug/
android/app/profile/ android/app/profile/
android/app/release/ android/app/release/
@@ -49,8 +50,15 @@ android/app/release/
# ----------------------------- # -----------------------------
ios/Flutter/ephemeral/ ios/Flutter/ephemeral/
ios/Flutter/.last_build_id 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/Pods/
ios/.symlinks/ ios/.symlinks/
ios/Runner/GeneratedPluginRegistrant.h
ios/Runner/GeneratedPluginRegistrant.m
ios/Runner.xcworkspace/xcuserdata/ ios/Runner.xcworkspace/xcuserdata/
ios/Runner.xcodeproj/xcuserdata/ ios/Runner.xcodeproj/xcuserdata/
@@ -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.
+23 -2
View File
@@ -25,7 +25,8 @@ flutter run --dart-define=USE_FAKE_BACKEND=true
## Background Sync ## Background Sync
Background sync (startup + resume + periodic ticks while authenticated) is 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 In REST mode, auto-triggers are reachability-gated to avoid unnecessary sync
attempts while offline. attempts while offline.
@@ -78,7 +79,23 @@ Behavior notes:
## Phone-Only Private AI Digest ## Phone-Only Private AI Digest
The app can run without a production backend by keeping `USE_FAKE_BACKEND=true` The app can run without a production backend by keeping `USE_FAKE_BACKEND=true`
and using the LLM integration only for a private weekly digest. 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: Digest behavior:
@@ -86,6 +103,10 @@ Digest behavior:
- scheduled digest payloads use pseudonymous tokens such as `person_001` - scheduled digest payloads use pseudonymous tokens such as `person_001`
- names, aliases, sender names, source URLs, raw shared text, and sensitive - names, aliases, sender names, source URLs, raw shared text, and sensitive
notes are not sent in the scheduled digest prompt 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 - LLM results are saved as pending AI review drafts first
- accepting a draft creates a local idea, task, or reminder - accepting a draft creates a local idea, task, or reminder
- dismissing a draft leaves existing local data unchanged - dismissing a draft leaves existing local data unchanged
-3
View File
@@ -32,9 +32,6 @@ target 'Runner' do
use_modular_headers! use_modular_headers!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'Share Extension' do
inherit! :search_paths
end
target 'RunnerTests' do target 'RunnerTests' do
inherit! :search_paths inherit! :search_paths
end end
+1 -1
View File
@@ -75,6 +75,6 @@ SPEC CHECKSUMS:
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778 workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
PODFILE CHECKSUM: 09dd68ff20d1f7e1497be5a3d807622c3053984a PODFILE CHECKSUM: 1959d098c91d8a792531a723c4a9d7e9f6a01e38
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2
+184 -221
View File
@@ -9,10 +9,6 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
15BE7299C8BED38E2BC0A6A0 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C88FBC0E3BAB70074730065 /* Pods_Runner.framework */; }; 15BE7299C8BED38E2BC0A6A0 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C88FBC0E3BAB70074730065 /* Pods_Runner.framework */; };
A1B2C3011111111111111111 /* Pods_Share_Extension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1B2C30B1111111111111111 /* Pods_Share_Extension.framework */; };
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, ); }; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
5E1E9B720DF095CAC1A2FF8A /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD3F9117486EC23DF50E2D6A /* Pods_RunnerTests.framework */; }; 5E1E9B720DF095CAC1A2FF8A /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD3F9117486EC23DF50E2D6A /* Pods_RunnerTests.framework */; };
@@ -21,16 +17,12 @@
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 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, ); }; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
A1B2C3051111111111111111 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = A1B2C3131111111111111111;
remoteInfo = "Share Extension";
};
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */; containerPortal = 97C146E61CF9000F007C117D /* Project object */;
@@ -38,9 +30,26 @@
remoteGlobalIDString = 97C146ED1CF9000F007C117D; remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner; remoteInfo = Runner;
}; };
A1B2C3051111111111111111 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = A1B2C3131111111111111111;
remoteInfo = "Share Extension";
};
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
A1B2C3061111111111111111 /* Embed App Extensions */ = { A1B2C3061111111111111111 /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase; isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -52,32 +61,12 @@
name = "Embed App Extensions"; name = "Embed App Extensions";
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
0E770A251CA9930160492DC4 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; 0E770A251CA9930160492DC4 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
A1B2C3071111111111111111 /* Pods-Share Extension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.debug.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.debug.xcconfig"; sourceTree = "<group>"; };
A1B2C3081111111111111111 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; };
A1B2C3091111111111111111 /* Pods-Share Extension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.profile.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.profile.xcconfig"; sourceTree = "<group>"; };
A1B2C30A1111111111111111 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
A1B2C30B1111111111111111 /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
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>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
@@ -96,20 +85,18 @@
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; };
CD3F9117486EC23DF50E2D6A /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 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>"; }; 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>"; }; 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>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
A1B2C3111111111111111111 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3011111111111111111 /* Pods_Share_Extension.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EB1CF9000F007C117D /* Frameworks */ = { 97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -118,6 +105,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
A1B2C3111111111111111111 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
E2BAEB33085D4BFE88A55D45 /* Frameworks */ = { E2BAEB33085D4BFE88A55D45 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -143,14 +137,10 @@
E9B506D809ABF2A50BF6135B /* Pods-Runner.debug.xcconfig */, E9B506D809ABF2A50BF6135B /* Pods-Runner.debug.xcconfig */,
9B7CFEE2CE4B7B1DCB85B3E1 /* Pods-Runner.release.xcconfig */, 9B7CFEE2CE4B7B1DCB85B3E1 /* Pods-Runner.release.xcconfig */,
0E770A251CA9930160492DC4 /* Pods-Runner.profile.xcconfig */, 0E770A251CA9930160492DC4 /* Pods-Runner.profile.xcconfig */,
A1B2C3071111111111111111 /* Pods-Share Extension.debug.xcconfig */,
A1B2C3081111111111111111 /* Pods-Share Extension.release.xcconfig */,
A1B2C3091111111111111111 /* Pods-Share Extension.profile.xcconfig */,
F2C7EE505B336533B62B23EB /* Pods-RunnerTests.debug.xcconfig */, F2C7EE505B336533B62B23EB /* Pods-RunnerTests.debug.xcconfig */,
9708213885153303B111D6D9 /* Pods-RunnerTests.release.xcconfig */, 9708213885153303B111D6D9 /* Pods-RunnerTests.release.xcconfig */,
6881917E7D40106AEFE87622 /* Pods-RunnerTests.profile.xcconfig */, 6881917E7D40106AEFE87622 /* Pods-RunnerTests.profile.xcconfig */,
); );
name = Pods;
path = Pods; path = Pods;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -158,7 +148,6 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
4C88FBC0E3BAB70074730065 /* Pods_Runner.framework */, 4C88FBC0E3BAB70074730065 /* Pods_Runner.framework */,
A1B2C30B1111111111111111 /* Pods_Share_Extension.framework */,
CD3F9117486EC23DF50E2D6A /* Pods_RunnerTests.framework */, CD3F9117486EC23DF50E2D6A /* Pods_RunnerTests.framework */,
); );
name = Frameworks; name = Frameworks;
@@ -229,24 +218,6 @@
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
A1B2C3131111111111111111 /* Share Extension */ = {
isa = PBXNativeTarget;
buildConfigurationList = A1B2C31C1111111111111111 /* Build configuration list for PBXNativeTarget "Share Extension" */;
buildPhases = (
A1B2C3151111111111111111 /* [CP] Check Pods Manifest.lock */,
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";
};
331C8080294A63A400263BE5 /* RunnerTests */ = { 331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
@@ -290,6 +261,23 @@
productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application"; 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";
};
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@@ -300,9 +288,6 @@
LastUpgradeCheck = 1510; LastUpgradeCheck = 1510;
ORGANIZATIONNAME = ""; ORGANIZATIONNAME = "";
TargetAttributes = { TargetAttributes = {
A1B2C3131111111111111111 = {
CreatedOnToolsVersion = 15.0.1;
};
331C8080294A63A400263BE5 = { 331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0; CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D; TestTargetID = 97C146ED1CF9000F007C117D;
@@ -311,6 +296,9 @@
CreatedOnToolsVersion = 7.3.1; CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100; LastSwiftMigration = 1100;
}; };
A1B2C3131111111111111111 = {
CreatedOnToolsVersion = 15.0.1;
};
}; };
}; };
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
@@ -334,14 +322,6 @@
/* End PBXProject section */ /* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */ /* Begin PBXResourcesBuildPhase section */
A1B2C3141111111111111111 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3031111111111111111 /* MainInterface.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
331C807F294A63A400263BE5 /* Resources */ = { 331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -360,31 +340,17 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
A1B2C3141111111111111111 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3031111111111111111 /* MainInterface.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */
A1B2C3151111111111111111 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Share Extension-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
38116227E31B7524BBB62C35 /* [CP] Check Pods Manifest.lock */ = { 38116227E31B7524BBB62C35 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -480,14 +446,6 @@
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
A1B2C3161111111111111111 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3021111111111111111 /* ShareViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
331C807D294A63A400263BE5 /* Sources */ = { 331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -506,30 +464,30 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
A1B2C3161111111111111111 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3021111111111111111 /* ShareViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
A1B2C3171111111111111111 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = A1B2C3131111111111111111 /* Share Extension */;
targetProxy = A1B2C3051111111111111111 /* PBXContainerItemProxy */;
};
331C8086294A63A400263BE5 /* PBXTargetDependency */ = { 331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */; target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
}; };
A1B2C3171111111111111111 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = A1B2C3131111111111111111 /* Share Extension */;
targetProxy = A1B2C3051111111111111111 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */ /* Begin PBXVariantGroup section */
A1B2C3181111111111111111 /* MainInterface.storyboard */ = {
isa = PBXVariantGroup;
children = (
A1B2C30E1111111111111111 /* Base */,
);
name = MainInterface.storyboard;
sourceTree = "<group>";
};
97C146FA1CF9000F007C117D /* Main.storyboard */ = { 97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup; isa = PBXVariantGroup;
children = ( children = (
@@ -546,17 +504,25 @@
name = LaunchScreen.storyboard; name = LaunchScreen.storyboard;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A1B2C3181111111111111111 /* MainInterface.storyboard */ = {
isa = PBXVariantGroup;
children = (
A1B2C30E1111111111111111 /* Base */,
);
name = MainInterface.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */ /* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = { 249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
APP_BUNDLE_IDENTIFIER = com.example.relationshipSaver; ALWAYS_SEARCH_USER_PATHS = NO;
APP_GROUP_ID = "group.$(APP_BUNDLE_IDENTIFIER).shared"; APP_BUNDLE_IDENTIFIER = com.rijadzuzo.relationshipSaver;
APP_GROUP_ID = group.com.rijadzuzo.relationshipSaver.shared;
APP_RUNNER_TESTS_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests"; APP_RUNNER_TESTS_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests";
APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShareExtension"; APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShareExtension";
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
@@ -613,9 +579,9 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = 1;
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)"; CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM_ID)"; DEVELOPMENT_TEAM = SR3UKQVKP6;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@@ -630,88 +596,6 @@
}; };
name = Profile; name = Profile;
}; };
A1B2C3191111111111111111 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = A1B2C3071111111111111111 /* Pods-Share Extension.debug.xcconfig */;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "Share Extension/Share Extension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM_ID)";
INFOPLIST_FILE = "Share Extension/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Relationship Saver Share";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
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;
baseConfigurationReference = A1B2C3081111111111111111 /* Pods-Share Extension.release.xcconfig */;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "Share Extension/Share Extension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM_ID)";
INFOPLIST_FILE = "Share Extension/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Relationship Saver Share";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
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;
baseConfigurationReference = A1B2C3091111111111111111 /* Pods-Share Extension.profile.xcconfig */;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "Share Extension/Share Extension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM_ID)";
INFOPLIST_FILE = "Share Extension/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Relationship Saver Share";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
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;
};
331C8088294A63A400263BE5 /* Debug */ = { 331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = F2C7EE505B336533B62B23EB /* Pods-RunnerTests.debug.xcconfig */; baseConfigurationReference = F2C7EE505B336533B62B23EB /* Pods-RunnerTests.debug.xcconfig */;
@@ -765,11 +649,11 @@
97C147031CF9000F007C117D /* Debug */ = { 97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
APP_BUNDLE_IDENTIFIER = com.example.relationshipSaver; ALWAYS_SEARCH_USER_PATHS = NO;
APP_GROUP_ID = "group.$(APP_BUNDLE_IDENTIFIER).shared"; APP_BUNDLE_IDENTIFIER = com.rijadzuzo.relationshipSaver;
APP_GROUP_ID = group.com.rijadzuzo.relationshipSaver.shared;
APP_RUNNER_TESTS_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests"; APP_RUNNER_TESTS_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests";
APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShareExtension"; APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShareExtension";
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
@@ -827,11 +711,11 @@
97C147041CF9000F007C117D /* Release */ = { 97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
APP_BUNDLE_IDENTIFIER = com.example.relationshipSaver; ALWAYS_SEARCH_USER_PATHS = NO;
APP_GROUP_ID = "group.$(APP_BUNDLE_IDENTIFIER).shared"; APP_BUNDLE_IDENTIFIER = com.rijadzuzo.relationshipSaver;
APP_GROUP_ID = group.com.rijadzuzo.relationshipSaver.shared;
APP_RUNNER_TESTS_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests"; APP_RUNNER_TESTS_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests";
APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShareExtension"; APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShareExtension";
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
@@ -892,7 +776,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)"; CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM_ID)"; DEVELOPMENT_TEAM = SR3UKQVKP6;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@@ -917,7 +801,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)"; CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM_ID)"; DEVELOPMENT_TEAM = SR3UKQVKP6;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@@ -932,19 +816,88 @@
}; };
name = Release; 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;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
A1B2C31C1111111111111111 /* Build configuration list for PBXNativeTarget "Share Extension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A1B2C3191111111111111111 /* Debug */,
A1B2C31A1111111111111111 /* Release */,
A1B2C31B1111111111111111 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
@@ -975,6 +928,16 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
A1B2C31C1111111111111111 /* Build configuration list for PBXNativeTarget "Share Extension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A1B2C3191111111111111111 /* Debug */,
A1B2C31A1111111111111111 /* Release */,
A1B2C31B1111111111111111 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = 97C146E61CF9000F007C117D /* Project object */; rootObject = 97C146E61CF9000F007C117D /* Project object */;
@@ -52,9 +52,9 @@
</Testables> </Testables>
</TestAction> </TestAction>
<LaunchAction <LaunchAction
buildConfiguration = "Debug" buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0" launchStyle = "0"
useCustomWorkingDirectory = "NO" useCustomWorkingDirectory = "NO"
+8 -8
View File
@@ -2,6 +2,12 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.relationshipsaver.llm.digest</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
@@ -12,8 +18,6 @@
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
@@ -24,8 +28,6 @@
<string>$(FLUTTER_BUILD_NAME)</string> <string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
<array> <array>
<dict> <dict>
@@ -37,6 +39,8 @@
</array> </array>
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
@@ -69,10 +73,6 @@
<string>fetch</string> <string>fetch</string>
<string>processing</string> <string>processing</string>
</array> </array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.relationshipsaver.llm.digest</string>
</array>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
+28 -14
View File
@@ -4,30 +4,44 @@
<dict> <dict>
<key>AppGroupId</key> <key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string> <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> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key> <key>NSExtension</key>
<dict> <dict>
<key>NSExtensionAttributes</key> <key>NSExtensionAttributes</key>
<dict> <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> <key>PHSupportedMediaTypes</key>
<array> <array>
<string>Video</string> <string>Video</string>
<string>Image</string> <string>Image</string>
</array> </array>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>20</integer>
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
<integer>10</integer>
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
<integer>5</integer>
</dict>
</dict> </dict>
<key>NSExtensionMainStoryboard</key> <key>NSExtensionMainStoryboard</key>
<string>MainInterface</string> <string>MainInterface</string>
+314 -2
View File
@@ -1,3 +1,315 @@
import receive_sharing_intent import MobileCoreServices
import Social
import UniformTypeIdentifiers
import UIKit
class ShareViewController: RSIShareViewController {} 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
}
}
@@ -96,17 +96,7 @@ extension LocalRepositoryShareOperations on LocalRepository {
); );
} }
bool createdProfile = false;
final List<PersonProfile> nextPeople = current.people.toList(
growable: true,
);
if (resolvedPerson == null) { if (resolvedPerson == null) {
final String? inferredName = _deriveAutoProfileName(
sourceDisplayName: sourceDisplayName,
sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId,
);
if (inferredName == null) {
return _queueSharedInbox( return _queueSharedInbox(
current: current, current: current,
payload: payload, payload: payload,
@@ -116,22 +106,6 @@ extension LocalRepositoryShareOperations on LocalRepository {
candidateProfileIds: const <String>[], candidateProfileIds: const <String>[],
); );
} }
createdProfile = true;
resolvedPerson = PersonProfile(
id: 'p-${_uuid.v4()}',
name: inferredName,
relationship: 'WhatsApp Contact',
affinityScore: 70,
nextMoment: now.add(const Duration(days: 2)),
tags: const <String>['whatsapp'],
notes: 'Auto-created from shared message.',
aliases: sourceDisplayName == null || sourceDisplayName == inferredName
? const <String>[]
: <String>[sourceDisplayName],
lastUpdatedAt: now,
);
nextPeople.insert(0, resolvedPerson);
}
return _ingestIntoResolvedProfile( return _ingestIntoResolvedProfile(
current: current, current: current,
@@ -141,8 +115,8 @@ extension LocalRepositoryShareOperations on LocalRepository {
normalizedDisplayName: normalizedDisplayName, normalizedDisplayName: normalizedDisplayName,
resolvedPerson: resolvedPerson, resolvedPerson: resolvedPerson,
matchedLink: matchedLink, matchedLink: matchedLink,
createdProfile: createdProfile, createdProfile: false,
people: nextPeople, people: current.people,
resolvedAutomatically: true, resolvedAutomatically: true,
draft: _defaultDraftForPayload(payload), draft: _defaultDraftForPayload(payload),
); );
@@ -298,14 +272,10 @@ extension LocalRepositoryShareOperations on LocalRepository {
}) async { }) async {
final LocalDataState current = _requireState(); final LocalDataState current = _requireState();
final DateTime now = DateTime.now(); final DateTime now = DateTime.now();
final String profileName = final String? profileName = _trimToNull(name);
_trimToNull(name) ?? if (profileName == null) {
_deriveAutoProfileName( throw ArgumentError('A person name is required to create from a share');
sourceDisplayName: payload.sourceDisplayName, }
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
) ??
'New Contact';
final String normalizedRelationship = relationship.trim().isEmpty final String normalizedRelationship = relationship.trim().isEmpty
? 'Contact' ? 'Contact'
: relationship.trim(); : relationship.trim();
@@ -842,16 +812,6 @@ SharedInboxEntry _requireSharedInboxEntry(
throw ArgumentError('Shared inbox entry not found'); throw ArgumentError('Shared inbox entry not found');
} }
String? _deriveAutoProfileName({
required String? sourceDisplayName,
required String? sourceUserId,
required String? sourceThreadId,
}) {
return _trimToNull(sourceDisplayName) ??
_trimToNull(sourceUserId) ??
_trimToNull(sourceThreadId);
}
String? _buildStableSourceFingerprint({ String? _buildStableSourceFingerprint({
required String sourceApp, required String sourceApp,
required String? sourceUserId, required String? sourceUserId,
+2 -10
View File
@@ -2,14 +2,9 @@ import 'package:flutter/material.dart';
/// Typed shell destinations used by the app navigation shell. /// Typed shell destinations used by the app navigation shell.
enum AppDestination { enum AppDestination {
dashboard('Dashboard', Icons.dashboard_rounded), shareInbox('Capture', Icons.inbox_rounded),
people('People', Icons.people_alt_rounded), people('People', Icons.people_alt_rounded),
moments('Moments', Icons.auto_awesome_rounded), aiReview('Digest', Icons.rate_review_rounded),
signals('Signals', Icons.tips_and_updates_rounded),
aiReview('AI Review', Icons.rate_review_rounded),
ideas('Ideas', Icons.lightbulb_outline_rounded),
reminders('Reminders', Icons.notifications_active_outlined),
sync('Sync', Icons.sync_rounded),
settings('Settings', Icons.settings_rounded); settings('Settings', Icons.settings_rounded);
const AppDestination(this.label, this.icon); const AppDestination(this.label, this.icon);
@@ -17,6 +12,3 @@ enum AppDestination {
final String label; final String label;
final IconData icon; final IconData icon;
} }
/// Secondary destinations exposed from the compact shell overflow menu.
enum CompactSecondaryDestination { aiReview, ideas, reminders, sync, settings }
+15 -114
View File
@@ -3,14 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/app/navigation/app_destination.dart'; import 'package:relationship_saver/app/navigation/app_destination.dart';
import 'package:relationship_saver/core/config/app_theme.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/ai_digest/presentation/ai_digest_review_view.dart';
import 'package:relationship_saver/features/dashboard/presentation/dashboard_view.dart';
import 'package:relationship_saver/features/ideas/presentation/ideas_view.dart';
import 'package:relationship_saver/features/moments/presentation/moments_view.dart';
import 'package:relationship_saver/features/people/presentation/people_view.dart'; import 'package:relationship_saver/features/people/presentation/people_view.dart';
import 'package:relationship_saver/features/reminders/presentation/reminders_view.dart';
import 'package:relationship_saver/features/settings/presentation/settings_view.dart'; import 'package:relationship_saver/features/settings/presentation/settings_view.dart';
import 'package:relationship_saver/features/signals/presentation/signals_view.dart'; import 'package:relationship_saver/features/share_intake/presentation/share_inbox_view.dart';
import 'package:relationship_saver/features/sync/presentation/sync_view.dart';
/// Responsive shell that hosts the main feature slices. /// Responsive shell that hosts the main feature slices.
class AppShell extends ConsumerStatefulWidget { class AppShell extends ConsumerStatefulWidget {
@@ -21,16 +16,11 @@ class AppShell extends ConsumerStatefulWidget {
} }
class _AppShellState extends ConsumerState<AppShell> { class _AppShellState extends ConsumerState<AppShell> {
AppDestination _destination = AppDestination.dashboard; AppDestination _destination = AppDestination.shareInbox;
static const List<AppDestination> _destinations = AppDestination.values; static const List<AppDestination> _destinations = AppDestination.values;
static const List<AppDestination> _compactPrimaryDestinations = static const List<AppDestination> _compactPrimaryDestinations =
<AppDestination>[ AppDestination.values;
AppDestination.dashboard,
AppDestination.people,
AppDestination.moments,
AppDestination.signals,
];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -65,6 +55,7 @@ class _AppShellState extends ConsumerState<AppShell> {
Expanded( Expanded(
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
layoutBuilder: _expandedSwitcherLayout,
child: KeyedSubtree( child: KeyedSubtree(
key: ValueKey<AppDestination>(_destination), key: ValueKey<AppDestination>(_destination),
child: _screenFor(_destination), child: _screenFor(_destination),
@@ -84,31 +75,10 @@ class _AppShellState extends ConsumerState<AppShell> {
final int compactIndex = _compactSelectedIndex(_destination); final int compactIndex = _compactSelectedIndex(_destination);
final AppDestination active = _compactPrimaryDestinations[compactIndex]; final AppDestination active = _compactPrimaryDestinations[compactIndex];
return _CompactShell( return _CompactShell(
title: active.label,
selected: active, selected: active,
body: _screenFor(active), body: _screenFor(active),
destinations: _compactPrimaryDestinations, destinations: _compactPrimaryDestinations,
onChange: _selectDestination, onChange: _selectDestination,
onOpenSecondary: (CompactSecondaryDestination destination) {
final AppDestination screenDestination = switch (destination) {
CompactSecondaryDestination.ideas => AppDestination.ideas,
CompactSecondaryDestination.aiReview => AppDestination.aiReview,
CompactSecondaryDestination.reminders => AppDestination.reminders,
CompactSecondaryDestination.sync => AppDestination.sync,
CompactSecondaryDestination.settings => AppDestination.settings,
};
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) {
return _CompactSecondaryScreen(
title: screenDestination.label,
child: _screenFor(screenDestination),
);
},
),
);
},
); );
} }
@@ -129,22 +99,12 @@ class _AppShellState extends ConsumerState<AppShell> {
Widget _screenFor(AppDestination destination) { Widget _screenFor(AppDestination destination) {
switch (destination) { switch (destination) {
case AppDestination.dashboard: case AppDestination.shareInbox:
return const DashboardView(); return const ShareInboxView();
case AppDestination.people: case AppDestination.people:
return const PeopleView(); return const PeopleView();
case AppDestination.moments:
return const MomentsView();
case AppDestination.signals:
return const SignalsView();
case AppDestination.aiReview: case AppDestination.aiReview:
return const AiDigestReviewView(); return const AiDigestReviewView();
case AppDestination.ideas:
return const IdeasView();
case AppDestination.reminders:
return const RemindersView();
case AppDestination.sync:
return const SyncView();
case AppDestination.settings: case AppDestination.settings:
return const SettingsView(); return const SettingsView();
} }
@@ -255,7 +215,7 @@ class _Sidebar extends StatelessWidget {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: Text( child: Text(
'Tip: Use Signals daily and convert at least one into a concrete plan.', 'Share messages or paste text into Capture. Nothing is assigned until you choose a person.',
style: Theme.of( style: Theme.of(
context, context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
@@ -269,69 +229,25 @@ class _Sidebar extends StatelessWidget {
class _CompactShell extends StatelessWidget { class _CompactShell extends StatelessWidget {
const _CompactShell({ const _CompactShell({
required this.title,
required this.selected, required this.selected,
required this.body, required this.body,
required this.destinations, required this.destinations,
required this.onChange, required this.onChange,
required this.onOpenSecondary,
}); });
final String title;
final AppDestination selected; final AppDestination selected;
final Widget body; final Widget body;
final List<AppDestination> destinations; final List<AppDestination> destinations;
final ValueChanged<AppDestination> onChange; final ValueChanged<AppDestination> onChange;
final ValueChanged<CompactSecondaryDestination> onOpenSecondary;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
children: <Widget>[ children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: <Widget>[
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleLarge,
),
),
PopupMenuButton<CompactSecondaryDestination>(
tooltip: 'More',
icon: const Icon(Icons.more_horiz_rounded),
onSelected: onOpenSecondary,
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<CompactSecondaryDestination>>[
const PopupMenuItem<CompactSecondaryDestination>(
value: CompactSecondaryDestination.aiReview,
child: Text('AI Review'),
),
const PopupMenuItem<CompactSecondaryDestination>(
value: CompactSecondaryDestination.ideas,
child: Text('Ideas'),
),
const PopupMenuItem<CompactSecondaryDestination>(
value: CompactSecondaryDestination.reminders,
child: Text('Reminders'),
),
const PopupMenuItem<CompactSecondaryDestination>(
value: CompactSecondaryDestination.sync,
child: Text('Sync'),
),
const PopupMenuItem<CompactSecondaryDestination>(
value: CompactSecondaryDestination.settings,
child: Text('Settings'),
),
],
),
],
),
),
Expanded( Expanded(
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: const Duration(milliseconds: 220), duration: const Duration(milliseconds: 220),
layoutBuilder: _expandedSwitcherLayout,
child: KeyedSubtree( child: KeyedSubtree(
key: ValueKey<AppDestination>(selected), key: ValueKey<AppDestination>(selected),
child: body, child: body,
@@ -355,27 +271,12 @@ class _CompactShell extends StatelessWidget {
} }
} }
class _CompactSecondaryScreen extends StatelessWidget { Widget _expandedSwitcherLayout(
const _CompactSecondaryScreen({required this.title, required this.child}); Widget? currentChild,
List<Widget> previousChildren,
final String title; ) {
final Widget child; return Stack(
fit: StackFit.expand,
@override children: <Widget>[...previousChildren, ?currentChild],
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: <Color>[Color(0xFFF3F8FB), Color(0xFFEAF1F8)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(title: Text(title)),
body: child,
),
); );
} }
}
+9 -156
View File
@@ -219,162 +219,15 @@ class LocalDataState {
); );
} }
static LocalDataState seed() { static LocalDataState empty() {
return LocalDataState( return const LocalDataState(
people: <PersonProfile>[ people: <PersonProfile>[],
PersonProfile( moments: <RelationshipMoment>[],
id: 'p-ava', ideas: <RelationshipIdea>[],
name: 'Ava Hart', reminders: <ReminderRule>[],
relationship: 'Partner', tasks: <DashboardTask>[],
affinityScore: 92,
nextMoment: DateTime(2026, 2, 16, 19, 30),
tags: <String>['coffee', 'books', 'slow mornings'],
notes: 'Prefers thoughtful plans over expensive plans.',
aliases: <String>['Aves'],
location: 'Austin, TX',
lastUpdatedAt: DateTime(2026, 2, 14, 9, 0),
lastInteractedAt: DateTime(2026, 2, 12, 18, 30),
),
PersonProfile(
id: 'p-jordan',
name: 'Jordan Lee',
relationship: 'Close Friend',
affinityScore: 78,
nextMoment: DateTime(2026, 2, 18, 18, 0),
tags: <String>['running', 'street food'],
notes: 'Has a race on Sunday; ask how training is going.',
location: 'Chicago, IL',
lastUpdatedAt: DateTime(2026, 2, 13, 8, 30),
lastInteractedAt: DateTime(2026, 2, 11, 19, 0),
),
PersonProfile(
id: 'p-mila',
name: 'Mila Stone',
relationship: 'Sister',
affinityScore: 84,
nextMoment: DateTime(2026, 2, 20, 12, 0),
tags: <String>['plants', 'retro music'],
notes: 'Birthday prep should start this week.',
aliases: <String>['Millie'],
location: 'Seattle, WA',
lastUpdatedAt: DateTime(2026, 2, 13, 14, 20),
lastInteractedAt: DateTime(2026, 2, 12, 14, 20),
),
],
moments: <RelationshipMoment>[
RelationshipMoment(
id: 'm-1',
personId: 'p-ava',
title: 'Sunday Walk Tradition',
summary: 'Short walk + no-phone hour worked really well.',
at: DateTime(2026, 2, 9, 9, 30),
type: 'ritual',
),
RelationshipMoment(
id: 'm-2',
personId: 'p-jordan',
title: 'Post-workout Check-in',
summary: 'Shared training playlist and grabbed smoothies.',
at: DateTime(2026, 2, 11, 19, 0),
type: 'support',
),
RelationshipMoment(
id: 'm-3',
personId: 'p-mila',
title: 'Gift Idea Captured',
summary: 'Found a vintage lamp from her saved style board.',
at: DateTime(2026, 2, 12, 14, 20),
type: 'gift',
),
],
ideas: <RelationshipIdea>[
RelationshipIdea(
id: 'i-1',
personId: 'p-ava',
type: IdeaType.gift,
title: 'Handwritten recipe journal',
details: 'Collect 10 memories and recipes from this year.',
createdAt: DateTime(2026, 2, 12, 9),
),
RelationshipIdea(
id: 'i-2',
personId: 'p-mila',
type: IdeaType.event,
title: 'Plant market + brunch date',
details: 'Saturday morning slot; book nearby cafe in advance.',
createdAt: DateTime(2026, 2, 13, 11),
),
],
reminders: <ReminderRule>[
ReminderRule(
id: 'r-1',
personId: 'p-jordan',
title: 'Weekly check-in message',
cadence: ReminderCadence.weekly,
nextAt: DateTime(2026, 2, 18, 18),
),
ReminderRule(
id: 'r-2',
personId: 'p-ava',
title: 'Sunday ritual planning',
cadence: ReminderCadence.weekly,
nextAt: DateTime(2026, 2, 16, 10),
),
],
tasks: <DashboardTask>[
DashboardTask(
id: 't-1',
title: 'Plan Friday dinner with Ava',
description: 'Keep it low-key: ramen + bookstore stop.',
when: DateTime(2026, 2, 16, 17, 0),
),
DashboardTask(
id: 't-2',
title: 'Send race-day encouragement to Jordan',
description: 'Voice note before 8:00 AM.',
when: DateTime(2026, 2, 17, 7, 30),
),
DashboardTask(
id: 't-3',
title: 'Finalize Mila birthday shortlist',
description: 'Pick 1 meaningful gift and 1 backup.',
when: DateTime(2026, 2, 18, 20, 0),
),
],
personFacts: <PersonFact>[
PersonFact(
id: 'pf-1',
personId: 'p-ava',
type: CapturedFactType.like,
text: 'Quiet brunch spots and recipe-book stores',
label: 'Weekend preference',
sourceKind: CaptureSourceKind.manual,
createdAt: DateTime(2026, 2, 10, 8, 0),
updatedAt: DateTime(2026, 2, 10, 8, 0),
),
PersonFact(
id: 'pf-2',
personId: 'p-mila',
type: CapturedFactType.giftIdea,
text: 'Vintage ceramic planter in muted green',
label: 'Gift lead',
sourceKind: CaptureSourceKind.manual,
createdAt: DateTime(2026, 2, 12, 14, 25),
updatedAt: DateTime(2026, 2, 12, 14, 25),
),
],
importantDates: <PersonImportantDate>[
PersonImportantDate(
id: 'pd-1',
personId: 'p-mila',
label: 'Birthday',
date: DateTime(2026, 3, 3),
classification: 'birthday',
sourceKind: CaptureSourceKind.manual,
createdAt: DateTime(2026, 2, 12, 14, 30),
updatedAt: DateTime(2026, 2, 12, 14, 30),
),
],
); );
} }
static LocalDataState seed() => empty();
} }
+1 -1
View File
@@ -33,7 +33,7 @@ class AppConfig {
/// Enables periodic startup/resume sync triggers while authenticated. /// Enables periodic startup/resume sync triggers while authenticated.
static bool get enableBackgroundSync => 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. /// Enables local notifications runtime for reminder delivery.
static bool get enableLocalNotifications => const bool.fromEnvironment( static bool get enableLocalNotifications => const bool.fromEnvironment(
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:relationship_saver/app/state/local_data_state.dart'; 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/people/domain/person_models.dart';
class AnonymizedLlmDigestContext { class AnonymizedLlmDigestContext {
@@ -22,11 +23,13 @@ class AnonymizedLlmContextBuilder {
const AnonymizedLlmContextBuilder({ const AnonymizedLlmContextBuilder({
this.maxPeople = 20, this.maxPeople = 20,
this.maxSignalsPerPerson = 8, this.maxSignalsPerPerson = 8,
this.maxPriorSuggestionsPerPerson = 6,
DateTime Function()? now, DateTime Function()? now,
}) : _now = now ?? DateTime.now; }) : _now = now ?? DateTime.now;
final int maxPeople; final int maxPeople;
final int maxSignalsPerPerson; final int maxSignalsPerPerson;
final int maxPriorSuggestionsPerPerson;
final DateTime Function() _now; final DateTime Function() _now;
AnonymizedLlmDigestContext build(LocalDataState state) { AnonymizedLlmDigestContext build(LocalDataState state) {
@@ -66,6 +69,7 @@ class AnonymizedLlmContextBuilder {
'Do not infer or ask for names.', 'Do not infer or ask for names.',
'Return JSON only.', 'Return JSON only.',
'Prefer practical suggestions that can be reviewed before saving.', 'Prefer practical suggestions that can be reviewed before saving.',
'Do not repeat priorSuggestions for the same personToken.',
], ],
'limits': <String, dynamic>{ 'limits': <String, dynamic>{
'maxItems': 10, 'maxItems': 10,
@@ -184,9 +188,27 @@ class AnonymizedLlmContextBuilder {
.map((PersonPreferenceSignal signal) => 'avoid ${signal.label}') .map((PersonPreferenceSignal signal) => 'avoid ${signal.label}')
.toList(growable: false), .toList(growable: false),
).take(maxSignalsPerPerson).toList(growable: false), ).take(maxSignalsPerPerson).toList(growable: false),
'priorSuggestions': _priorSuggestions(state, person),
}; };
} }
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) { String _relationshipCategory(String value) {
final String normalized = value.toLowerCase(); final String normalized = value.toLowerCase();
if (_containsAny(normalized, <String>[ if (_containsAny(normalized, <String>[
@@ -391,11 +391,11 @@ class _PersonDetail extends ConsumerWidget {
] ]
: sharedMessages : sharedMessages
.take(compact ? 3 : 5) .take(compact ? 3 : 5)
.map( .map<Widget>(
(SharedMessageEntry entry) => _PeopleInsightRow( (SharedMessageEntry entry) => _PeopleInsightRow(
title: title:
entry.sourceDisplayName?.trim().isNotEmpty == true entry.sourceDisplayName?.trim().isNotEmpty == true
? entry.sourceDisplayName!.trim() ? 'Detected in text: ${entry.sourceDisplayName!.trim()}'
: 'Shared from ${entry.sourceApp}', : 'Shared from ${entry.sourceApp}',
subtitle: entry.messageText.trim().isEmpty subtitle: entry.messageText.trim().isEmpty
? 'Imported message content is empty' ? 'Imported message content is empty'
@@ -404,6 +404,23 @@ class _PersonDetail extends ConsumerWidget {
'${entry.resolvedAutomatically ? 'AUTO-LINKED' : 'MANUAL-LINK'}${_shortDateTimeLabel(entry.sharedAt)}', '${entry.resolvedAutomatically ? 'AUTO-LINKED' : 'MANUAL-LINK'}${_shortDateTimeLabel(entry.sharedAt)}',
), ),
) )
.followedBy(<Widget>[
if (sharedMessages.length > (compact ? 3 : 5))
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => _showSharedMessageHistory(
context,
person: person,
sharedMessages: sharedMessages,
),
icon: const Icon(Icons.list_alt_rounded),
label: Text(
'View all ${sharedMessages.length} shared texts',
),
),
),
])
.toList(growable: false), .toList(growable: false),
), ),
], ],
@@ -411,6 +428,70 @@ class _PersonDetail extends ConsumerWidget {
} }
} }
Future<void> _showSharedMessageHistory(
BuildContext context, {
required PersonProfile person,
required List<SharedMessageEntry> sharedMessages,
}) {
return showModalBottomSheet<void>(
context: context,
useSafeArea: true,
isScrollControlled: true,
showDragHandle: true,
builder: (BuildContext context) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.82,
minChildSize: 0.45,
maxChildSize: 0.95,
builder: (BuildContext context, ScrollController scrollController) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Shared texts',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
'${person.name}${sharedMessages.length} item${sharedMessages.length == 1 ? '' : 's'}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 12),
Expanded(
child: ListView.separated(
controller: scrollController,
itemCount: sharedMessages.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (BuildContext context, int index) {
final SharedMessageEntry entry = sharedMessages[index];
return _PeopleInsightRow(
title:
entry.sourceDisplayName?.trim().isNotEmpty == true
? 'Detected in text: ${entry.sourceDisplayName!.trim()}'
: 'Shared from ${entry.sourceApp}',
subtitle: entry.messageText.trim().isEmpty
? entry.url ?? 'Imported message content is empty'
: entry.messageText.trim(),
meta:
'${entry.sourceApp.toUpperCase()}${entry.resolvedAutomatically ? 'AUTO-LINKED' : 'MANUAL-LINK'}${_shortDateTimeLabel(entry.sharedAt)}',
);
},
),
),
],
),
);
},
);
},
);
}
Future<void> _showPeoplePreferenceEvidence( Future<void> _showPeoplePreferenceEvidence(
BuildContext context, { BuildContext context, {
required PersonPreferenceSignal signal, required PersonPreferenceSignal signal,
@@ -10,6 +10,7 @@ import 'package:relationship_saver/features/ai_digest/presentation/ai_digest_rev
import 'package:relationship_saver/features/auth/session_controller.dart'; import 'package:relationship_saver/features/auth/session_controller.dart';
import 'package:relationship_saver/features/local/local_models.dart'; import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart'; import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart';
import 'package:relationship_saver/features/share_intake/application/share_intake_debug_log.dart';
import 'package:relationship_saver/features/share_intake/share_capture_flow.dart'; import 'package:relationship_saver/features/share_intake/share_capture_flow.dart';
import 'package:relationship_saver/features/share_intake/share_capture_models.dart'; import 'package:relationship_saver/features/share_intake/share_capture_models.dart';
import 'package:relationship_saver/features/share_intake/share_inbox_view.dart'; import 'package:relationship_saver/features/share_intake/share_inbox_view.dart';
@@ -210,6 +211,11 @@ class SettingsView extends ConsumerWidget {
icon: const Icon(Icons.inbox_rounded), icon: const Icon(Icons.inbox_rounded),
label: const Text('Open Share Inbox'), label: const Text('Open Share Inbox'),
), ),
OutlinedButton.icon(
onPressed: () => _showShareDiagnostics(context),
icon: const Icon(Icons.bug_report_rounded),
label: const Text('Share Diagnostics'),
),
], ],
), ),
], ],
@@ -411,6 +417,26 @@ class SettingsView extends ConsumerWidget {
), ),
); );
} }
Future<void> _showShareDiagnostics(BuildContext context) async {
final ShareIntakeDebugLog debugLog = const ShareIntakeDebugLog();
final List<ShareIntakeDebugEvent> events = await debugLog.read();
if (!context.mounted) {
return;
}
await showDialog<void>(
context: context,
builder: (BuildContext context) => _ShareDiagnosticsDialog(
events: events,
onClear: () async {
await debugLog.clear();
if (context.mounted) {
Navigator.of(context).pop();
}
},
),
);
}
} }
class _SettingRow extends StatelessWidget { class _SettingRow extends StatelessWidget {
@@ -462,6 +488,57 @@ class _SettingRow extends StatelessWidget {
} }
} }
class _ShareDiagnosticsDialog extends StatelessWidget {
const _ShareDiagnosticsDialog({required this.events, required this.onClear});
final List<ShareIntakeDebugEvent> events;
final Future<void> Function() onClear;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Share Diagnostics'),
content: SizedBox(
width: 520,
child: events.isEmpty
? const Text(
'No Flutter share events recorded yet. If you tried sharing, the native share extension likely did not open the app or the plugin did not receive the URL callback.',
)
: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: events
.map(
(ShareIntakeDebugEvent event) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: SelectableText(
'${_debugTimeLabel(event.at)} ${event.stage}\n${event.message}',
),
),
)
.toList(growable: false),
),
),
),
actions: <Widget>[
TextButton(
onPressed: events.isEmpty ? null : onClear,
child: const Text('Clear'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
);
}
}
String _debugTimeLabel(DateTime value) {
return '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}:${value.second.toString().padLeft(2, '0')}';
}
class _LlmConfigDraft { class _LlmConfigDraft {
const _LlmConfigDraft({ const _LlmConfigDraft({
required this.provider, required this.provider,
@@ -39,12 +39,7 @@ Future<SharedMessageIngestResult?> startShareCaptureReview(
required WidgetRef ref, required WidgetRef ref,
required SharedPayload payload, required SharedPayload payload,
}) { }) {
return showShareCaptureReviewSheet( return showShareCaptureReviewSheet(context, ref: ref, payload: payload);
context,
ref: ref,
payload: payload,
initialPersonId: ref.read(selectedPersonIdProvider),
);
} }
void openShareCaptureDestination( void openShareCaptureDestination(
@@ -73,10 +68,10 @@ void openShareCaptureDestination(
String shareCaptureResultMessage(SharedMessageIngestResult result) { String shareCaptureResultMessage(SharedMessageIngestResult result) {
if (result.isQueuedForResolution) { if (result.isQueuedForResolution) {
return 'Saved shared capture to inbox.'; return 'Saved shared text to Capture inbox.';
} }
if (result.createdProfile) { if (result.createdProfile) {
return 'Saved shared capture and created ${result.profileName}.'; return 'Saved shared text and created ${result.profileName}.';
} }
return 'Saved shared capture to ${result.profileName}.'; return 'Saved shared text to ${result.profileName}.';
} }
@@ -0,0 +1,79 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
const String _shareIntakeDebugLogKey = 'share_intake_debug_log_v1';
const int _maxShareIntakeDebugEvents = 40;
class ShareIntakeDebugEvent {
const ShareIntakeDebugEvent({
required this.at,
required this.stage,
required this.message,
});
factory ShareIntakeDebugEvent.fromJson(Map<String, dynamic> json) {
return ShareIntakeDebugEvent(
at: DateTime.parse(
json['at'] as String? ?? DateTime.now().toUtc().toIso8601String(),
).toLocal(),
stage: json['stage'] as String? ?? 'unknown',
message: json['message'] as String? ?? '',
);
}
final DateTime at;
final String stage;
final String message;
Map<String, dynamic> toJson() {
return <String, dynamic>{
'at': at.toUtc().toIso8601String(),
'stage': stage,
'message': message,
};
}
}
class ShareIntakeDebugLog {
const ShareIntakeDebugLog();
Future<List<ShareIntakeDebugEvent>> read() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
final String? raw = prefs.getString(_shareIntakeDebugLogKey);
if (raw == null || raw.isEmpty) {
return const <ShareIntakeDebugEvent>[];
}
try {
final List<dynamic> items = jsonDecode(raw) as List<dynamic>;
return items
.whereType<Map<String, dynamic>>()
.map(ShareIntakeDebugEvent.fromJson)
.toList(growable: false);
} on FormatException {
return const <ShareIntakeDebugEvent>[];
}
}
Future<void> record(String stage, String message) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
final List<ShareIntakeDebugEvent> current = await read();
final List<ShareIntakeDebugEvent> next = <ShareIntakeDebugEvent>[
ShareIntakeDebugEvent(at: DateTime.now(), stage: stage, message: message),
...current,
].take(_maxShareIntakeDebugEvents).toList(growable: false);
await prefs.setString(
_shareIntakeDebugLogKey,
jsonEncode(
next
.map((ShareIntakeDebugEvent event) => event.toJson())
.toList(growable: false),
),
);
}
Future<void> clear() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.remove(_shareIntakeDebugLogKey);
}
}
@@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:relationship_saver/core/config/app_config.dart'; import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/features/local/local_models.dart'; import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/share_intake/application/share_intake_debug_log.dart';
import 'package:relationship_saver/features/share_intake/share_capture_flow.dart'; import 'package:relationship_saver/features/share_intake/share_capture_flow.dart';
import 'package:relationship_saver/features/share_intake/share_capture_models.dart'; import 'package:relationship_saver/features/share_intake/share_capture_models.dart';
@@ -23,6 +24,7 @@ class IncomingShareListener extends ConsumerStatefulWidget {
class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> { class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> {
StreamSubscription<List<SharedMediaFile>>? _mediaSubscription; StreamSubscription<List<SharedMediaFile>>? _mediaSubscription;
String? _lastToken; String? _lastToken;
final ShareIntakeDebugLog _debugLog = const ShareIntakeDebugLog();
@override @override
void initState() { void initState() {
@@ -41,25 +43,44 @@ class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> {
Future<void> _start() async { Future<void> _start() async {
if (!AppConfig.enableWhatsAppShareIntake || kIsWeb) { if (!AppConfig.enableWhatsAppShareIntake || kIsWeb) {
await _debugLog.record(
'listener_skipped',
'enabled=${AppConfig.enableWhatsAppShareIntake}, kIsWeb=$kIsWeb',
);
return; return;
} }
try { try {
await _debugLog.record('listener_start', 'Subscribing to share intents.');
_mediaSubscription = ReceiveSharingIntent.instance _mediaSubscription = ReceiveSharingIntent.instance
.getMediaStream() .getMediaStream()
.listen( .listen(
(List<SharedMediaFile> files) => (List<SharedMediaFile> files) {
_handleMediaList(files, autoOpenDestination: false), unawaited(
onError: (_) {}, _debugLog.record(
'media_stream',
'Received ${files.length} shared item(s).',
),
);
unawaited(_handleMediaList(files, autoOpenDestination: false));
},
onError: (Object error) {
unawaited(_debugLog.record('media_stream_error', '$error'));
},
); );
final List<SharedMediaFile> initialMedia = await ReceiveSharingIntent final List<SharedMediaFile> initialMedia = await ReceiveSharingIntent
.instance .instance
.getInitialMedia(); .getInitialMedia();
await _debugLog.record(
'initial_media',
'Received ${initialMedia.length} initial shared item(s).',
);
if (initialMedia.isNotEmpty) { if (initialMedia.isNotEmpty) {
await _handleMediaList(initialMedia, autoOpenDestination: true); await _handleMediaList(initialMedia, autoOpenDestination: true);
} }
} catch (_) { } catch (error) {
await _debugLog.record('listener_error', '$error');
// Unsupported platform/plugin state should not break app usage. // Unsupported platform/plugin state should not break app usage.
} }
} }
@@ -69,8 +90,16 @@ class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> {
required bool autoOpenDestination, required bool autoOpenDestination,
}) async { }) async {
for (final SharedMediaFile file in files) { for (final SharedMediaFile file in files) {
await _debugLog.record(
'media_item',
'type=${file.type.name}, mime=${file.mimeType ?? 'none'}, pathLength=${file.path.length}, messageLength=${file.message?.length ?? 0}',
);
final String? rawText = _extractText(file); final String? rawText = _extractText(file);
if (rawText == null) { if (rawText == null) {
await _debugLog.record(
'extract_skip',
'No usable text for type=${file.type.name}.',
);
continue; continue;
} }
await _handleRawText( await _handleRawText(
@@ -129,12 +158,21 @@ class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> {
sourceAppHint: sourceAppHint, sourceAppHint: sourceAppHint,
); );
if (payload == null) { if (payload == null) {
await _debugLog.record(
'payload_skip',
'Parser returned null for rawLength=${trimmed.length}.',
);
return; return;
} }
await _debugLog.record(
'payload_parsed',
'source=${payload.sourceApp}, type=${payload.payloadType.name}, rawLength=${payload.rawText.length}, hasUrl=${payload.url != null}, detectedSender=${payload.sourceDisplayName != null}',
);
final String token = final String token =
'${payload.sourceApp}:${payload.sourceUserId ?? payload.sourceDisplayName ?? ''}:${payload.rawText}:${payload.url ?? ''}'; '${payload.sourceApp}:${payload.sourceUserId ?? payload.sourceDisplayName ?? ''}:${payload.rawText}:${payload.url ?? ''}';
if (_lastToken == token) { if (_lastToken == token) {
await _debugLog.record('payload_duplicate', 'Ignored duplicate share.');
return; return;
} }
_lastToken = token; _lastToken = token;
@@ -149,8 +187,17 @@ class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> {
); );
if (!mounted || result == null) { if (!mounted || result == null) {
await _debugLog.record('review_cancelled', 'Share review closed.');
return; return;
} }
unawaited(
_debugLog.record(
'review_result',
result.isQueuedForResolution
? 'Queued for inbox.'
: 'Saved to profile; createdProfile=${result.createdProfile}.',
),
);
if (autoOpenDestination) { if (autoOpenDestination) {
openShareCaptureDestination(context, ref: ref, result: result); openShareCaptureDestination(context, ref: ref, result: result);
@@ -88,12 +88,12 @@ class _ShareCaptureReviewSheetState
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
'Save Shared Capture', 'Save Shared Text',
style: Theme.of(context).textTheme.headlineSmall, style: Theme.of(context).textTheme.headlineSmall,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'Review the incoming text or link, then assign it safely.', 'Choose who this belongs to, or keep it in the inbox for later review.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.textSecondary, color: AppTheme.textSecondary,
), ),
@@ -101,10 +101,7 @@ class _ShareCaptureReviewSheetState
const SizedBox(height: 14), const SizedBox(height: 14),
_PayloadPreview(payload: widget.payload), _PayloadPreview(payload: widget.payload),
const SizedBox(height: 14), const SizedBox(height: 14),
Text( Text('Type', style: Theme.of(context).textTheme.titleMedium),
'What kind of info is this?',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8), const SizedBox(height: 8),
Wrap( Wrap(
spacing: 8, spacing: 8,
@@ -344,9 +341,7 @@ class _ShareCaptureReviewSheetState
await showDialog<_CreatePersonFromShareDraft>( await showDialog<_CreatePersonFromShareDraft>(
context: context, context: context,
builder: (BuildContext context) => _CreatePersonFromShareDialog( builder: (BuildContext context) => _CreatePersonFromShareDialog(
initialName: detectedName: widget.payload.sourceDisplayName,
widget.payload.sourceDisplayName ??
widget.payload.rawText.split(RegExp(r'\s+')).take(2).join(' '),
), ),
); );
if (draft == null) { if (draft == null) {
@@ -445,7 +440,7 @@ class _PayloadPreview extends StatelessWidget {
if (payload.sourceDisplayName != null) ...<Widget>[ if (payload.sourceDisplayName != null) ...<Widget>[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Sender: ${payload.sourceDisplayName}', 'Detected in text: ${payload.sourceDisplayName}',
style: Theme.of( style: Theme.of(
context, context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
@@ -484,9 +479,9 @@ class _CreatePersonFromShareDraft {
} }
class _CreatePersonFromShareDialog extends StatefulWidget { class _CreatePersonFromShareDialog extends StatefulWidget {
const _CreatePersonFromShareDialog({required this.initialName}); const _CreatePersonFromShareDialog({this.detectedName});
final String initialName; final String? detectedName;
@override @override
State<_CreatePersonFromShareDialog> createState() => State<_CreatePersonFromShareDialog> createState() =>
@@ -504,7 +499,7 @@ class _CreatePersonFromShareDialogState
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_nameController = TextEditingController(text: widget.initialName); _nameController = TextEditingController();
} }
@override @override
@@ -523,9 +518,21 @@ class _CreatePersonFromShareDialogState
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
if (widget.detectedName?.trim().isNotEmpty == true) ...<Widget>[
Text(
'Detected message author: ${widget.detectedName!.trim()}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 10),
],
TextField( TextField(
controller: _nameController, controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'), decoration: const InputDecoration(
labelText: 'Person name',
hintText: 'Enter the real contact',
),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
TextField( TextField(
@@ -4,6 +4,8 @@ import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/local/local_models.dart'; import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/local/local_repository.dart'; import 'package:relationship_saver/features/local/local_repository.dart';
import 'package:relationship_saver/features/share_intake/share_capture_models.dart'; import 'package:relationship_saver/features/share_intake/share_capture_models.dart';
import 'package:relationship_saver/features/share_intake/share_capture_review_sheet.dart';
import 'package:relationship_saver/features/share_intake/share_payload_parser.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart'; import 'package:relationship_saver/features/shared/frosted_card.dart';
/// Review unresolved shared messages and map them to a person profile. /// Review unresolved shared messages and map them to a person profile.
@@ -44,14 +46,26 @@ class _ShareInboxViewState extends ConsumerState<ShareInboxView> {
data: (LocalDataState localState) { data: (LocalDataState localState) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Wrap(
spacing: 12,
runSpacing: 10,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[ children: <Widget>[
Text( Text(
'Share Inbox', 'Capture',
style: Theme.of(context).textTheme.headlineMedium, style: Theme.of(context).textTheme.headlineMedium,
), ),
FilledButton.icon(
onPressed: _submitting ? null : _addTextCapture,
icon: const Icon(Icons.edit_note_rounded),
label: const Text('Add Text'),
),
],
),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'Resolve shared messages that could not be matched confidently.', 'Share messages here, paste copied text, then choose or create the right person.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: AppTheme.textSecondary, color: AppTheme.textSecondary,
), ),
@@ -59,7 +73,9 @@ class _ShareInboxViewState extends ConsumerState<ShareInboxView> {
const SizedBox(height: 14), const SizedBox(height: 14),
Expanded( Expanded(
child: localState.sharedInbox.isEmpty child: localState.sharedInbox.isEmpty
? _EmptyInbox(compact: compact) ? SingleChildScrollView(
child: _EmptyInbox(compact: compact),
)
: ListView.separated( : ListView.separated(
itemCount: localState.sharedInbox.length, itemCount: localState.sharedInbox.length,
separatorBuilder: (_, _) => separatorBuilder: (_, _) =>
@@ -178,13 +194,8 @@ class _ShareInboxViewState extends ConsumerState<ShareInboxView> {
} }
final _CreateProfileDraft? draft = await showDialog<_CreateProfileDraft>( final _CreateProfileDraft? draft = await showDialog<_CreateProfileDraft>(
context: context, context: context,
builder: (BuildContext context) => _CreateProfileDialog( builder: (BuildContext context) =>
initialName: _CreateProfileDialog(detectedName: entry.sourceDisplayName),
entry.sourceDisplayName ??
entry.sourceUserId ??
entry.sourceThreadId ??
'',
),
); );
if (draft == null || !mounted) { if (draft == null || !mounted) {
return; return;
@@ -232,6 +243,38 @@ class _ShareInboxViewState extends ConsumerState<ShareInboxView> {
}); });
} }
Future<void> _addTextCapture() async {
if (_submitting) {
return;
}
final String? rawText = await showDialog<String>(
context: context,
builder: (BuildContext context) => const _AddTextDialog(),
);
if (rawText == null || rawText.trim().isEmpty || !mounted) {
return;
}
final SharedPayload payload = SharePayloadParser.parse(
rawText: rawText,
sourceApp: 'manual',
platform: 'manual',
createdAt: DateTime.now(),
receivedAt: DateTime.now(),
);
final SharedMessageIngestResult? result = await showShareCaptureReviewSheet(
context,
ref: ref,
payload: payload,
);
if (result == null || !mounted) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(_captureResultMessage(result))));
}
Future<void> _runRepositoryAction(Future<void> Function() action) async { Future<void> _runRepositoryAction(Future<void> Function() action) async {
setState(() { setState(() {
_submitting = true; _submitting = true;
@@ -453,6 +496,60 @@ class _ShareInboxViewState extends ConsumerState<ShareInboxView> {
} }
} }
class _AddTextDialog extends StatefulWidget {
const _AddTextDialog();
@override
State<_AddTextDialog> createState() => _AddTextDialogState();
}
class _AddTextDialogState extends State<_AddTextDialog> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Add Text'),
content: SizedBox(
width: 520,
child: TextField(
controller: _controller,
autofocus: true,
minLines: 5,
maxLines: 10,
textInputAction: TextInputAction.newline,
decoration: const InputDecoration(
labelText: 'Shared or copied text',
hintText: 'Paste a message, note, link, preference, or date...',
),
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(_controller.text),
child: const Text('Review'),
),
],
);
}
}
class _CandidateReview { class _CandidateReview {
const _CandidateReview({ const _CandidateReview({
required this.person, required this.person,
@@ -1073,6 +1170,16 @@ String _factTypeLabel(CapturedFactType type) {
}; };
} }
String _captureResultMessage(SharedMessageIngestResult result) {
if (result.isQueuedForResolution) {
return 'Saved to Capture inbox.';
}
if (result.createdProfile) {
return 'Saved and created ${result.profileName}.';
}
return 'Saved to ${result.profileName}.';
}
class _EmptyInbox extends StatelessWidget { class _EmptyInbox extends StatelessWidget {
const _EmptyInbox({required this.compact}); const _EmptyInbox({required this.compact});
@@ -1103,7 +1210,7 @@ class _EmptyInbox extends StatelessWidget {
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'Incoming shared captures that need manual person mapping or fact triage will appear here.', 'Share a message into the app or use Add Text to start building your private relationship notes.',
style: Theme.of( style: Theme.of(
context, context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
@@ -1132,9 +1239,9 @@ class _CreateProfileDraft {
} }
class _CreateProfileDialog extends StatefulWidget { class _CreateProfileDialog extends StatefulWidget {
const _CreateProfileDialog({required this.initialName}); const _CreateProfileDialog({this.detectedName});
final String initialName; final String? detectedName;
@override @override
State<_CreateProfileDialog> createState() => _CreateProfileDialogState(); State<_CreateProfileDialog> createState() => _CreateProfileDialogState();
@@ -1149,7 +1256,7 @@ class _CreateProfileDialogState extends State<_CreateProfileDialog> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_nameController = TextEditingController(text: widget.initialName); _nameController = TextEditingController();
_relationshipController = TextEditingController(text: 'Contact'); _relationshipController = TextEditingController(text: 'Contact');
_locationController = TextEditingController(); _locationController = TextEditingController();
_aliasesController = TextEditingController(); _aliasesController = TextEditingController();
@@ -1172,9 +1279,21 @@ class _CreateProfileDialogState extends State<_CreateProfileDialog> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
if (widget.detectedName?.trim().isNotEmpty == true) ...<Widget>[
Text(
'Detected message author: ${widget.detectedName!.trim()}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 8),
],
TextField( TextField(
controller: _nameController, controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'), decoration: const InputDecoration(
labelText: 'Person name',
hintText: 'Enter the real contact',
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
TextField( TextField(
@@ -97,21 +97,9 @@ class BackendGatewayFake implements BackendGateway {
@override @override
Future<SyncPullResult> pullChanges({String? cursor, int limit = 200}) async { Future<SyncPullResult> pullChanges({String? cursor, int limit = 200}) async {
final List<ChangeEnvelope> changes = <ChangeEnvelope>[
ChangeEnvelope(
schemaVersion: 1,
entityType: 'person',
entityId: 'person-1',
op: ChangeOperation.upsert,
modifiedAt: _seedTime,
clientMutationId: 'cm-1',
payload: const <String, dynamic>{'name': 'Alex', 'importance': 5},
),
];
return SyncPullResult( return SyncPullResult(
cursor: 'fake-pull-cursor-1', cursor: cursor ?? 'fake-pull-cursor-empty',
changes: changes.take(limit).toList(), changes: const <ChangeEnvelope>[],
); );
} }
@@ -21,10 +21,7 @@ void main() {
final ProviderContainer container = _createContainer(); final ProviderContainer container = _createContainer();
addTearDown(container.dispose); addTearDown(container.dispose);
final LocalDataState initial = await container.read( final String personId = await _addTestPerson(container);
localRepositoryProvider.future,
);
final String personId = initial.people.first.id;
await container await container
.read(localRepositoryProvider.notifier) .read(localRepositoryProvider.notifier)
@@ -65,10 +62,10 @@ void main() {
final ProviderContainer container = _createContainer(); final ProviderContainer container = _createContainer();
addTearDown(container.dispose); addTearDown(container.dispose);
final String personId = await _addTestPerson(container);
final LocalDataState initial = await container.read( final LocalDataState initial = await container.read(
localRepositoryProvider.future, localRepositoryProvider.future,
); );
final String personId = initial.people.first.id;
final int ideaCount = initial.ideas.length; final int ideaCount = initial.ideas.length;
await container await container
@@ -112,3 +109,19 @@ ProviderContainer _createContainer() {
], ],
); );
} }
Future<String> _addTestPerson(ProviderContainer container) async {
await container.read(localRepositoryProvider.future);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Taylor Quinn',
relationship: 'Friend',
notes: '',
tags: const <String>[],
);
final LocalDataState state = await container.read(
localRepositoryProvider.future,
);
return state.people.single.id;
}
@@ -1,6 +1,7 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/app/state/local_data_state.dart'; import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/features/ai_digest/application/anonymized_llm_context_builder.dart'; import 'package:relationship_saver/features/ai_digest/application/anonymized_llm_context_builder.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/dashboard/domain/dashboard_models.dart';
import 'package:relationship_saver/features/ideas/domain/idea_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/moments/domain/moment_models.dart';
@@ -73,6 +74,19 @@ void main() {
isSensitive: true, isSensitive: true,
), ),
], ],
aiSuggestionDrafts: <AiSuggestionDraft>[
AiSuggestionDraft(
id: 'ai-1',
personId: 'p-1',
kind: AiSuggestionKind.eventIdea,
title: 'Jazz concert downtown',
details: 'Should not need to repeat the details.',
suggestedAt: DateTime(2026, 5, 10),
confidence: 0.8,
status: AiSuggestionStatus.dismissed,
sourceRunId: 'digest-old',
),
],
); );
final AnonymizedLlmDigestContext context = AnonymizedLlmContextBuilder( final AnonymizedLlmDigestContext context = AnonymizedLlmContextBuilder(
@@ -90,5 +104,9 @@ void main() {
expect(prompt, isNot(contains('thread-secret'))); expect(prompt, isNot(contains('thread-secret')));
expect(prompt, isNot(contains('Raw private chat text'))); expect(prompt, isNot(contains('Raw private chat text')));
expect(prompt, isNot(contains('Secret medical detail'))); expect(prompt, isNot(contains('Secret medical detail')));
expect(prompt, contains('priorSuggestions'));
expect(prompt, contains('jazz concert downtown'));
expect(prompt, contains('dismissed'));
expect(prompt, isNot(contains('Should not need to repeat the details.')));
}); });
} }
@@ -48,6 +48,15 @@ void main() {
); );
addTearDown(container.dispose); addTearDown(container.dispose);
await container.read(localRepositoryProvider.future);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Taylor Quinn',
relationship: 'Friend',
notes: '',
tags: const <String>['tea'],
);
await container.read(llmDigestConfigProvider.notifier).setEnabled(true); await container.read(llmDigestConfigProvider.notifier).setEnabled(true);
final LlmDigestRunResult result = await container final LlmDigestRunResult result = await container
+1 -1
View File
@@ -40,7 +40,7 @@ void main() {
await tester.tap(find.widgetWithText(FilledButton, 'Sign In')); await tester.tap(find.widgetWithText(FilledButton, 'Sign In'));
await tester.pumpAndSettle(const Duration(milliseconds: 450)); await tester.pumpAndSettle(const Duration(milliseconds: 450));
expect(find.text('Dashboard'), findsWidgets); expect(find.text('Capture'), findsWidgets);
await tester.tap(find.text('Settings').first); await tester.tap(find.text('Settings').first);
await tester.pumpAndSettle(const Duration(milliseconds: 450)); await tester.pumpAndSettle(const Duration(milliseconds: 450));
@@ -47,7 +47,7 @@ void main() {
await tester.tap(find.widgetWithText(FilledButton, 'Sign In')); await tester.tap(find.widgetWithText(FilledButton, 'Sign In'));
await tester.pumpAndSettle(const Duration(milliseconds: 500)); await tester.pumpAndSettle(const Duration(milliseconds: 500));
expect(find.text('Dashboard'), findsWidgets); expect(find.text('Capture'), findsWidgets);
await tester.tap(find.text('People').last); await tester.tap(find.text('People').last);
await tester.pumpAndSettle(const Duration(milliseconds: 500)); await tester.pumpAndSettle(const Duration(milliseconds: 500));
@@ -91,13 +91,5 @@ void main() {
}), }),
isTrue, isTrue,
); );
await tester.tap(find.byTooltip('More'));
await tester.pumpAndSettle(const Duration(milliseconds: 250));
await tester.tap(find.text('Sync').last);
await tester.pumpAndSettle(const Duration(milliseconds: 500));
expect(find.text('Queue Status'), findsOneWidget);
expect(find.text('Pending changes'), findsOneWidget);
}); });
} }
@@ -25,13 +25,23 @@ void main() {
); );
addTearDown(container.dispose); addTearDown(container.dispose);
await container.read(localRepositoryProvider.future);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Ava Hart',
relationship: 'Friend',
notes: '',
tags: const <String>[],
);
final LocalDataState seeded = await container.read( final LocalDataState seeded = await container.read(
localRepositoryProvider.future, localRepositoryProvider.future,
); );
final String personId = seeded.people.first.id;
await container await container
.read(localRepositoryProvider.notifier) .read(localRepositoryProvider.notifier)
.upsertInferredPreferenceSignalObservation( .upsertInferredPreferenceSignalObservation(
personId: seeded.people.first.id, personId: personId,
key: 'drink:tea', key: 'drink:tea',
label: 'Tea', label: 'Tea',
category: 'drink', category: 'drink',
@@ -58,7 +68,7 @@ void main() {
expect(find.text('Explore'), findsOneWidget); expect(find.text('Explore'), findsOneWidget);
final Finder avaNode = find.byKey( final Finder avaNode = find.byKey(
const ValueKey<String>('graph-node-p-ava'), ValueKey<String>('graph-node-$personId'),
); );
expect(avaNode, findsOneWidget); expect(avaNode, findsOneWidget);
+92 -32
View File
@@ -17,15 +17,17 @@ void main() {
SharedPreferences.setMockInitialValues(<String, Object>{}); SharedPreferences.setMockInitialValues(<String, Object>{});
}); });
test('seeds local data on first load', () async { test('starts with empty local data on first load', () async {
final ProviderContainer container = _createContainer(); final ProviderContainer container = _createContainer();
addTearDown(container.dispose); addTearDown(container.dispose);
final state = await container.read(localRepositoryProvider.future); final state = await container.read(localRepositoryProvider.future);
expect(state.people, isNotEmpty); expect(state.people, isEmpty);
expect(state.moments, isNotEmpty); expect(state.moments, isEmpty);
expect(state.tasks, isNotEmpty); expect(state.ideas, isEmpty);
expect(state.reminders, isEmpty);
expect(state.tasks, isEmpty);
}); });
test('adds and removes person with persistence state update', () async { test('adds and removes person with persistence state update', () async {
@@ -62,8 +64,8 @@ void main() {
final ProviderContainer container = _createContainer(); final ProviderContainer container = _createContainer();
addTearDown(container.dispose); addTearDown(container.dispose);
final String personId = await _addTestPerson(container);
final state = await container.read(localRepositoryProvider.future); final state = await container.read(localRepositoryProvider.future);
final String personId = state.people.first.id;
final int beforeCount = state.moments.length; final int beforeCount = state.moments.length;
await container await container
@@ -86,10 +88,7 @@ void main() {
final ProviderContainer container = _createContainer(); final ProviderContainer container = _createContainer();
addTearDown(container.dispose); addTearDown(container.dispose);
final LocalDataState state = await container.read( final String personId = await _addTestPerson(container);
localRepositoryProvider.future,
);
final String personId = state.people.first.id;
final PersonPreferenceSignal first = await container final PersonPreferenceSignal first = await container
.read(localRepositoryProvider.notifier) .read(localRepositoryProvider.notifier)
@@ -161,7 +160,7 @@ void main() {
}); });
test( test(
'ingestSharedMessage auto-creates profile and reuses source link for follow-up messages', 'ingestSharedMessage queues unknown identity, then reuses source link after manual creation',
() async { () async {
final ProviderContainer container = _createContainer(); final ProviderContainer container = _createContainer();
addTearDown(container.dispose); addTearDown(container.dispose);
@@ -184,22 +183,35 @@ void main() {
final LocalDataState afterFirst = await container.read( final LocalDataState afterFirst = await container.read(
localRepositoryProvider.future, localRepositoryProvider.future,
); );
expect(first.createdProfile, isTrue); expect(first.isQueuedForResolution, isTrue);
expect(afterFirst.people.length, before.people.length + 1); expect(first.createdProfile, isFalse);
final SourceProfileLink createdLink = afterFirst.sourceLinks.firstWhere( expect(afterFirst.people.length, before.people.length);
(SourceProfileLink link) => link.profileId == first.profileId, expect(
afterFirst.sharedInbox.single.reason,
SharedInboxReason.missingIdentity,
);
final SharedMessageIngestResult created = await container
.read(localRepositoryProvider.notifier)
.resolveSharedInboxByCreatingProfile(
inboxEntryId: first.inboxEntryId!,
name: 'Taylor Quinn',
relationship: 'Friend',
);
final LocalDataState afterCreate = await container.read(
localRepositoryProvider.future,
);
expect(created.createdProfile, isTrue);
expect(afterCreate.people.length, before.people.length + 1);
final SourceProfileLink createdLink = afterCreate.sourceLinks.firstWhere(
(SourceProfileLink link) => link.profileId == created.profileId,
); );
expect(createdLink.sourceFingerprint, 'whatsapp|u:wa:taylor quinn'); expect(createdLink.sourceFingerprint, 'whatsapp|u:wa:taylor quinn');
expect( expect(
afterFirst.sourceLinks.any( afterCreate.moments.any(
(SourceProfileLink link) => link.profileId == first.profileId,
),
isTrue,
);
expect(
afterFirst.moments.any(
(RelationshipMoment moment) => (RelationshipMoment moment) =>
moment.personId == first.profileId && moment.type == 'whatsapp', moment.personId == created.profileId && moment.type == 'whatsapp',
), ),
isTrue, isTrue,
); );
@@ -219,11 +231,11 @@ void main() {
localRepositoryProvider.future, localRepositoryProvider.future,
); );
expect(second.createdProfile, isFalse); expect(second.createdProfile, isFalse);
expect(second.profileId, first.profileId); expect(second.profileId, created.profileId);
expect(afterSecond.people.length, afterFirst.people.length); expect(afterSecond.people.length, afterCreate.people.length);
expect( expect(
afterSecond.sharedMessages.length, afterSecond.sharedMessages.length,
afterFirst.sharedMessages.length + 1, afterCreate.sharedMessages.length + 1,
); );
}, },
); );
@@ -234,7 +246,7 @@ void main() {
final ProviderContainer container = _createContainer(); final ProviderContainer container = _createContainer();
addTearDown(container.dispose); addTearDown(container.dispose);
await container.read(localRepositoryProvider.future); await _addTestPerson(container, name: 'Nora Diaz');
final SharedMessageIngestResult result = await container final SharedMessageIngestResult result = await container
.read(localRepositoryProvider.notifier) .read(localRepositoryProvider.notifier)
@@ -285,10 +297,7 @@ void main() {
final ProviderContainer container = _createContainer(); final ProviderContainer container = _createContainer();
addTearDown(container.dispose); addTearDown(container.dispose);
final LocalDataState state = await container.read( final String personId = await _addTestPerson(container);
localRepositoryProvider.future,
);
final String personId = state.people.first.id;
final SharedPayload payload = SharePayloadParser.parse( final SharedPayload payload = SharePayloadParser.parse(
rawText: 'She loves vegan sushi and rooftop dinners.', rawText: 'She loves vegan sushi and rooftop dinners.',
sourceApp: 'share_sheet', sourceApp: 'share_sheet',
@@ -378,6 +387,35 @@ void main() {
}, },
); );
test(
'captureSharedPayloadByCreatingProfile requires an explicit person name',
() async {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
await container.read(localRepositoryProvider.future);
final SharedPayload payload = SharePayloadParser.parse(
rawText: 'Alex: Kaffee klingt gut',
sourceApp: 'whatsapp',
platform: 'ios',
);
expect(payload.sourceDisplayName, 'Alex');
expect(
() => container
.read(localRepositoryProvider.notifier)
.captureSharedPayloadByCreatingProfile(payload: payload),
throwsArgumentError,
);
final LocalDataState after = await container.read(
localRepositoryProvider.future,
);
expect(after.people, isEmpty);
expect(after.sharedMessages, isEmpty);
},
);
test( test(
'queues near-match conflict and then uses fingerprint mapping for follow-up shares', 'queues near-match conflict and then uses fingerprint mapping for follow-up shares',
() async { () async {
@@ -689,10 +727,10 @@ void main() {
final ProviderContainer container = _createContainer(); final ProviderContainer container = _createContainer();
addTearDown(container.dispose); addTearDown(container.dispose);
final String personId = await _addTestPerson(container);
final LocalDataState state = await container.read( final LocalDataState state = await container.read(
localRepositoryProvider.future, localRepositoryProvider.future,
); );
final String personId = state.people.first.id;
final int beforeCount = state.ideas.length; final int beforeCount = state.ideas.length;
await container await container
@@ -740,10 +778,10 @@ void main() {
final ProviderContainer container = _createContainer(); final ProviderContainer container = _createContainer();
addTearDown(container.dispose); addTearDown(container.dispose);
final String personId = await _addTestPerson(container);
final LocalDataState state = await container.read( final LocalDataState state = await container.read(
localRepositoryProvider.future, localRepositoryProvider.future,
); );
final String personId = state.people.first.id;
final int beforeCount = state.reminders.length; final int beforeCount = state.reminders.length;
await container await container
@@ -852,6 +890,28 @@ ProviderContainer _createContainer({ReminderScheduler? scheduler}) {
); );
} }
Future<String> _addTestPerson(
ProviderContainer container, {
String name = 'Test Person',
String relationship = 'Friend',
}) async {
await container.read(localRepositoryProvider.future);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: name,
relationship: relationship,
notes: '',
tags: const <String>[],
);
final LocalDataState state = await container.read(
localRepositoryProvider.future,
);
return state.people
.firstWhere((PersonProfile person) => person.name == name)
.id;
}
class RecordingReminderScheduler implements ReminderScheduler { class RecordingReminderScheduler implements ReminderScheduler {
final List<List<ReminderRule>> snapshots = <List<ReminderRule>>[]; final List<List<ReminderRule>> snapshots = <List<ReminderRule>>[];
@@ -19,7 +19,7 @@ void main() {
addTearDown(container.dispose); addTearDown(container.dispose);
final initial = await container.read(localRepositoryProvider.future); final initial = await container.read(localRepositoryProvider.future);
expect(initial.people, isNotEmpty); expect(initial.people, isEmpty);
await container await container
.read(localRepositoryProvider.notifier) .read(localRepositoryProvider.notifier)
@@ -30,6 +30,15 @@ void main() {
); );
addTearDown(container.dispose); addTearDown(container.dispose);
await container.read(localRepositoryProvider.future);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Taylor Quinn',
relationship: 'Friend',
notes: '',
tags: const <String>[],
);
final LocalDataState initial = await container.read( final LocalDataState initial = await container.read(
localRepositoryProvider.future, localRepositoryProvider.future,
); );
@@ -65,7 +65,7 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Share Inbox'), findsOneWidget); expect(find.text('Capture'), findsOneWidget);
expect(find.text('Ambiguous match'), findsOneWidget); expect(find.text('Ambiguous match'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Review & Save'), findsOneWidget); expect(find.widgetWithText(FilledButton, 'Review & Save'), findsOneWidget);
@@ -10,7 +10,7 @@ import 'package:relationship_saver/features/sync/sync_coordinator.dart';
void main() { void main() {
testWidgets( testWidgets(
'triggers sync when reachability transitions from offline to online', 'does not trigger sync when background sync is disabled by default',
(WidgetTester tester) async { (WidgetTester tester) async {
final _TestReachability reachability = _TestReachability(initial: false); final _TestReachability reachability = _TestReachability(initial: false);
addTearDown(reachability.dispose); addTearDown(reachability.dispose);
@@ -39,22 +39,21 @@ void main() {
); );
await tester.pumpAndSettle(const Duration(milliseconds: 60)); await tester.pumpAndSettle(const Duration(milliseconds: 60));
// Startup trigger always runs once when background sync is enabled. expect(syncCalls, 0);
expect(syncCalls, 1);
reachability.emit(false); reachability.emit(false);
await tester.pumpAndSettle(const Duration(milliseconds: 40)); await tester.pumpAndSettle(const Duration(milliseconds: 40));
expect(syncCalls, 1); expect(syncCalls, 0);
reachability.emit(true); reachability.emit(true);
await tester.pumpAndSettle(const Duration(milliseconds: 60)); await tester.pumpAndSettle(const Duration(milliseconds: 60));
expect(syncCalls, 2); expect(syncCalls, 0);
}, },
); );
testWidgets('does not trigger extra sync while connectivity stays online', ( testWidgets(
WidgetTester tester, 'does not trigger sync while connectivity stays online by default',
) async { (WidgetTester tester) async {
final _TestReachability reachability = _TestReachability(initial: true); final _TestReachability reachability = _TestReachability(initial: true);
addTearDown(reachability.dispose); addTearDown(reachability.dispose);
@@ -82,13 +81,14 @@ void main() {
); );
await tester.pumpAndSettle(const Duration(milliseconds: 60)); await tester.pumpAndSettle(const Duration(milliseconds: 60));
expect(syncCalls, 1); expect(syncCalls, 0);
reachability.emit(true); reachability.emit(true);
await tester.pumpAndSettle(const Duration(milliseconds: 60)); await tester.pumpAndSettle(const Duration(milliseconds: 60));
expect(syncCalls, 1); expect(syncCalls, 0);
}); },
);
} }
class _TestSyncCoordinator extends SyncCoordinator { class _TestSyncCoordinator extends SyncCoordinator {
@@ -111,6 +111,29 @@ void main() {
); );
expect(queueState.cursor, 'cursor-remote-1'); expect(queueState.cursor, 'cursor-remote-1');
}); });
test('default fake pull is inert and does not seed people', () async {
final ProviderContainer container = ProviderContainer(
overrides: [
backendGatewayProvider.overrideWithValue(BackendGatewayFake()),
localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()),
syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()),
],
);
addTearDown(container.dispose);
await container.read(localRepositoryProvider.future);
final SyncRunResult result = await container
.read(syncCoordinatorProvider)
.pullRemote();
expect(result.success, isTrue);
expect(result.pulled, 0);
final localState = await container.read(localRepositoryProvider.future);
expect(localState.people, isEmpty);
});
} }
class _TestGateway extends BackendGatewayFake { class _TestGateway extends BackendGatewayFake {
@@ -23,6 +23,15 @@ void main() {
); );
addTearDown(container.dispose); addTearDown(container.dispose);
await container.read(localRepositoryProvider.future);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Repair Person',
relationship: 'Friend',
notes: '',
tags: const <String>[],
);
final localBefore = await container.read(localRepositoryProvider.future); final localBefore = await container.read(localRepositoryProvider.future);
final person = localBefore.people.first; final person = localBefore.people.first;