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/.gradle/
android/local.properties
android/app/debug/
android/app/profile/
android/app/release/
@@ -49,8 +50,15 @@ android/app/release/
# -----------------------------
ios/Flutter/ephemeral/
ios/Flutter/.last_build_id
ios/Flutter/Flutter.podspec
ios/Flutter/Generated.xcconfig
ios/Flutter/flutter_export_environment.sh
ios/Flutter/App.framework/
ios/Flutter/Flutter.framework/
ios/Pods/
ios/.symlinks/
ios/Runner/GeneratedPluginRegistrant.h
ios/Runner/GeneratedPluginRegistrant.m
ios/Runner.xcworkspace/xcuserdata/
ios/Runner.xcodeproj/xcuserdata/
@@ -0,0 +1,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 (startup + resume + periodic ticks while authenticated) is
enabled by default.
disabled by default. The current product direction is backendless/local-first,
so backend sync should be enabled only for REST transport development.
In REST mode, auto-triggers are reachability-gated to avoid unnecessary sync
attempts while offline.
@@ -78,7 +79,23 @@ Behavior notes:
## Phone-Only Private AI Digest
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:
@@ -86,6 +103,10 @@ Digest behavior:
- scheduled digest payloads use pseudonymous tokens such as `person_001`
- names, aliases, sender names, source URLs, raw shared text, and sensitive
notes are not sent in the scheduled digest prompt
- repeated LLM work should be avoided by fingerprinting unresolved/new share
batches and skipping extraction when the same batch has already completed
- weekly recommendation prompts should include previously suggested/dismissed
items so the model avoids repeats
- LLM results are saved as pending AI review drafts first
- accepting a draft creates a local idea, task, or reminder
- dismissing a draft leaves existing local data unchanged
-3
View File
@@ -32,9 +32,6 @@ target 'Runner' do
use_modular_headers!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'Share Extension' do
inherit! :search_paths
end
target 'RunnerTests' do
inherit! :search_paths
end
+1 -1
View File
@@ -75,6 +75,6 @@ SPEC CHECKSUMS:
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
PODFILE CHECKSUM: 09dd68ff20d1f7e1497be5a3d807622c3053984a
PODFILE CHECKSUM: 1959d098c91d8a792531a723c4a9d7e9f6a01e38
COCOAPODS: 1.16.2
+274 -311
View File
@@ -9,10 +9,6 @@
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
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 */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
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 */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
A1B2C3021111111111111111 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C30D1111111111111111 /* ShareViewController.swift */; };
A1B2C3031111111111111111 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A1B2C3181111111111111111 /* MainInterface.storyboard */; };
A1B2C3041111111111111111 /* Share Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = A1B2C30C1111111111111111 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
A1B2C3051111111111111111 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = A1B2C3131111111111111111;
remoteInfo = "Share Extension";
};
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
@@ -38,9 +30,26 @@
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
A1B2C3051111111111111111 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = A1B2C3131111111111111111;
remoteInfo = "Share Extension";
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
A1B2C3061111111111111111 /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
@@ -52,32 +61,12 @@
name = "Embed App Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase 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>"; };
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>"; };
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>"; };
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>"; };
@@ -96,20 +85,18 @@
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
9B7CFEE2CE4B7B1DCB85B3E1 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
A1B2C30A1111111111111111 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
A1B2C30C1111111111111111 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
A1B2C30D1111111111111111 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
A1B2C30E1111111111111111 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; };
A1B2C30F1111111111111111 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A1B2C3101111111111111111 /* Share Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Share Extension.entitlements"; sourceTree = "<group>"; };
CD3F9117486EC23DF50E2D6A /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
E9B506D809ABF2A50BF6135B /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
F2C7EE505B336533B62B23EB /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
A1B2C3111111111111111111 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3011111111111111111 /* Pods_Share_Extension.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -118,6 +105,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
A1B2C3111111111111111111 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
E2BAEB33085D4BFE88A55D45 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -143,14 +137,10 @@
E9B506D809ABF2A50BF6135B /* Pods-Runner.debug.xcconfig */,
9B7CFEE2CE4B7B1DCB85B3E1 /* Pods-Runner.release.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 */,
9708213885153303B111D6D9 /* Pods-RunnerTests.release.xcconfig */,
6881917E7D40106AEFE87622 /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
@@ -158,7 +148,6 @@
isa = PBXGroup;
children = (
4C88FBC0E3BAB70074730065 /* Pods_Runner.framework */,
A1B2C30B1111111111111111 /* Pods_Share_Extension.framework */,
CD3F9117486EC23DF50E2D6A /* Pods_RunnerTests.framework */,
);
name = Frameworks;
@@ -229,24 +218,6 @@
/* End PBXGroup 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 */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
@@ -290,6 +261,23 @@
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
A1B2C3131111111111111111 /* Share Extension */ = {
isa = PBXNativeTarget;
buildConfigurationList = A1B2C31C1111111111111111 /* Build configuration list for PBXNativeTarget "Share Extension" */;
buildPhases = (
A1B2C3161111111111111111 /* Sources */,
A1B2C3111111111111111111 /* Frameworks */,
A1B2C3141111111111111111 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = "Share Extension";
productName = "Share Extension";
productReference = A1B2C30C1111111111111111 /* Share Extension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -300,9 +288,6 @@
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
A1B2C3131111111111111111 = {
CreatedOnToolsVersion = 15.0.1;
};
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
@@ -311,6 +296,9 @@
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
A1B2C3131111111111111111 = {
CreatedOnToolsVersion = 15.0.1;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
@@ -334,14 +322,6 @@
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
A1B2C3141111111111111111 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3031111111111111111 /* MainInterface.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -360,31 +340,17 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
A1B2C3141111111111111111 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3031111111111111111 /* MainInterface.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase 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 */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -480,14 +446,6 @@
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
A1B2C3161111111111111111 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3021111111111111111 /* ShareViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -506,30 +464,30 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
A1B2C3161111111111111111 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3021111111111111111 /* ShareViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
A1B2C3171111111111111111 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = A1B2C3131111111111111111 /* Share Extension */;
targetProxy = A1B2C3051111111111111111 /* PBXContainerItemProxy */;
};
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
A1B2C3171111111111111111 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = A1B2C3131111111111111111 /* Share Extension */;
targetProxy = A1B2C3051111111111111111 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
A1B2C3181111111111111111 /* MainInterface.storyboard */ = {
isa = PBXVariantGroup;
children = (
A1B2C30E1111111111111111 /* Base */,
);
name = MainInterface.storyboard;
sourceTree = "<group>";
};
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
@@ -546,18 +504,26 @@
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
A1B2C3181111111111111111 /* MainInterface.storyboard */ = {
isa = PBXVariantGroup;
children = (
A1B2C30E1111111111111111 /* Base */,
);
name = MainInterface.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
APP_BUNDLE_IDENTIFIER = com.example.relationshipSaver;
APP_GROUP_ID = "group.$(APP_BUNDLE_IDENTIFIER).shared";
APP_RUNNER_TESTS_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests";
APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShareExtension";
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
APP_BUNDLE_IDENTIFIER = com.rijadzuzo.relationshipSaver;
APP_GROUP_ID = group.com.rijadzuzo.relationshipSaver.shared;
APP_RUNNER_TESTS_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests";
APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShareExtension";
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@@ -584,10 +550,10 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM_ID = "";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM_ID = "";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
@@ -608,122 +574,40 @@
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM_ID)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1;
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = SR3UKQVKP6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
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 */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = F2C7EE505B336533B62B23EB /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_RUNNER_TESTS_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_RUNNER_TESTS_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -733,15 +617,15 @@
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9708213885153303B111D6D9 /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_RUNNER_TESTS_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_RUNNER_TESTS_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
@@ -750,27 +634,27 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 6881917E7D40106AEFE87622 /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_RUNNER_TESTS_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_RUNNER_TESTS_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
APP_BUNDLE_IDENTIFIER = com.example.relationshipSaver;
APP_GROUP_ID = "group.$(APP_BUNDLE_IDENTIFIER).shared";
APP_RUNNER_TESTS_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests";
APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShareExtension";
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
APP_BUNDLE_IDENTIFIER = com.rijadzuzo.relationshipSaver;
APP_GROUP_ID = group.com.rijadzuzo.relationshipSaver.shared;
APP_RUNNER_TESTS_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests";
APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShareExtension";
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@@ -797,10 +681,10 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM_ID = "";
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM_ID = "";
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
@@ -824,15 +708,15 @@
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
APP_BUNDLE_IDENTIFIER = com.example.relationshipSaver;
APP_GROUP_ID = "group.$(APP_BUNDLE_IDENTIFIER).shared";
APP_RUNNER_TESTS_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests";
APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShareExtension";
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
APP_BUNDLE_IDENTIFIER = com.rijadzuzo.relationshipSaver;
APP_GROUP_ID = group.com.rijadzuzo.relationshipSaver.shared;
APP_RUNNER_TESTS_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests";
APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShareExtension";
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@@ -859,10 +743,10 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM_ID = "";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM_ID = "";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
@@ -885,23 +769,23 @@
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM_ID)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = SR3UKQVKP6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
@@ -910,41 +794,110 @@
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM_ID)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = SR3UKQVKP6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
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 */
/* 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" */ = {
isa = XCConfigurationList;
buildConfigurations = (
@@ -975,6 +928,16 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
A1B2C31C1111111111111111 /* Build configuration list for PBXNativeTarget "Share Extension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A1B2C3191111111111111111 /* Debug */,
A1B2C31A1111111111111111 /* Release */,
A1B2C31B1111111111111111 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
@@ -52,9 +52,9 @@
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
buildConfiguration = "Release"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
+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">
<plist version="1.0">
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.relationshipsaver.llm.digest</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
@@ -12,8 +18,6 @@
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
@@ -24,8 +28,6 @@
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -37,6 +39,8 @@
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSPhotoLibraryUsageDescription</key>
@@ -69,10 +73,6 @@
<string>fetch</string>
<string>processing</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.relationshipsaver.llm.digest</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
+28 -14
View File
@@ -4,30 +4,44 @@
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>CFBundleDisplayName</key>
<string>Relationship Saver</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
<integer>5</integer>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>20</integer>
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
<integer>10</integer>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
</dict>
<key>PHSupportedMediaTypes</key>
<array>
<string>Video</string>
<string>Image</string>
</array>
<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>
<key>NSExtensionMainStoryboard</key>
<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
}
}
+13 -53
View File
@@ -96,41 +96,15 @@ extension LocalRepositoryShareOperations on LocalRepository {
);
}
bool createdProfile = false;
final List<PersonProfile> nextPeople = current.people.toList(
growable: true,
);
if (resolvedPerson == null) {
final String? inferredName = _deriveAutoProfileName(
sourceDisplayName: sourceDisplayName,
sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId,
return _queueSharedInbox(
current: current,
payload: payload,
sourceFingerprint: sourceFingerprint,
normalizedDisplayName: normalizedDisplayName,
reason: SharedInboxReason.missingIdentity,
candidateProfileIds: const <String>[],
);
if (inferredName == null) {
return _queueSharedInbox(
current: current,
payload: payload,
sourceFingerprint: sourceFingerprint,
normalizedDisplayName: normalizedDisplayName,
reason: SharedInboxReason.missingIdentity,
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(
@@ -141,8 +115,8 @@ extension LocalRepositoryShareOperations on LocalRepository {
normalizedDisplayName: normalizedDisplayName,
resolvedPerson: resolvedPerson,
matchedLink: matchedLink,
createdProfile: createdProfile,
people: nextPeople,
createdProfile: false,
people: current.people,
resolvedAutomatically: true,
draft: _defaultDraftForPayload(payload),
);
@@ -298,14 +272,10 @@ extension LocalRepositoryShareOperations on LocalRepository {
}) async {
final LocalDataState current = _requireState();
final DateTime now = DateTime.now();
final String profileName =
_trimToNull(name) ??
_deriveAutoProfileName(
sourceDisplayName: payload.sourceDisplayName,
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
) ??
'New Contact';
final String? profileName = _trimToNull(name);
if (profileName == null) {
throw ArgumentError('A person name is required to create from a share');
}
final String normalizedRelationship = relationship.trim().isEmpty
? 'Contact'
: relationship.trim();
@@ -842,16 +812,6 @@ SharedInboxEntry _requireSharedInboxEntry(
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({
required String sourceApp,
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.
enum AppDestination {
dashboard('Dashboard', Icons.dashboard_rounded),
shareInbox('Capture', Icons.inbox_rounded),
people('People', Icons.people_alt_rounded),
moments('Moments', Icons.auto_awesome_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),
aiReview('Digest', Icons.rate_review_rounded),
settings('Settings', Icons.settings_rounded);
const AppDestination(this.label, this.icon);
@@ -17,6 +12,3 @@ enum AppDestination {
final String label;
final IconData icon;
}
/// Secondary destinations exposed from the compact shell overflow menu.
enum CompactSecondaryDestination { aiReview, ideas, reminders, sync, settings }
+16 -115
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/core/config/app_theme.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/reminders/presentation/reminders_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/sync/presentation/sync_view.dart';
import 'package:relationship_saver/features/share_intake/presentation/share_inbox_view.dart';
/// Responsive shell that hosts the main feature slices.
class AppShell extends ConsumerStatefulWidget {
@@ -21,16 +16,11 @@ class AppShell extends ConsumerStatefulWidget {
}
class _AppShellState extends ConsumerState<AppShell> {
AppDestination _destination = AppDestination.dashboard;
AppDestination _destination = AppDestination.shareInbox;
static const List<AppDestination> _destinations = AppDestination.values;
static const List<AppDestination> _compactPrimaryDestinations =
<AppDestination>[
AppDestination.dashboard,
AppDestination.people,
AppDestination.moments,
AppDestination.signals,
];
AppDestination.values;
@override
Widget build(BuildContext context) {
@@ -65,6 +55,7 @@ class _AppShellState extends ConsumerState<AppShell> {
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
layoutBuilder: _expandedSwitcherLayout,
child: KeyedSubtree(
key: ValueKey<AppDestination>(_destination),
child: _screenFor(_destination),
@@ -84,31 +75,10 @@ class _AppShellState extends ConsumerState<AppShell> {
final int compactIndex = _compactSelectedIndex(_destination);
final AppDestination active = _compactPrimaryDestinations[compactIndex];
return _CompactShell(
title: active.label,
selected: active,
body: _screenFor(active),
destinations: _compactPrimaryDestinations,
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) {
switch (destination) {
case AppDestination.dashboard:
return const DashboardView();
case AppDestination.shareInbox:
return const ShareInboxView();
case AppDestination.people:
return const PeopleView();
case AppDestination.moments:
return const MomentsView();
case AppDestination.signals:
return const SignalsView();
case AppDestination.aiReview:
return const AiDigestReviewView();
case AppDestination.ideas:
return const IdeasView();
case AppDestination.reminders:
return const RemindersView();
case AppDestination.sync:
return const SyncView();
case AppDestination.settings:
return const SettingsView();
}
@@ -255,7 +215,7 @@ class _Sidebar extends StatelessWidget {
borderRadius: BorderRadius.circular(16),
),
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(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
@@ -269,69 +229,25 @@ class _Sidebar extends StatelessWidget {
class _CompactShell extends StatelessWidget {
const _CompactShell({
required this.title,
required this.selected,
required this.body,
required this.destinations,
required this.onChange,
required this.onOpenSecondary,
});
final String title;
final AppDestination selected;
final Widget body;
final List<AppDestination> destinations;
final ValueChanged<AppDestination> onChange;
final ValueChanged<CompactSecondaryDestination> onOpenSecondary;
@override
Widget build(BuildContext context) {
return Column(
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(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
layoutBuilder: _expandedSwitcherLayout,
child: KeyedSubtree(
key: ValueKey<AppDestination>(selected),
child: body,
@@ -355,27 +271,12 @@ class _CompactShell extends StatelessWidget {
}
}
class _CompactSecondaryScreen extends StatelessWidget {
const _CompactSecondaryScreen({required this.title, required this.child});
final String title;
final Widget child;
@override
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,
),
);
}
Widget _expandedSwitcherLayout(
Widget? currentChild,
List<Widget> previousChildren,
) {
return Stack(
fit: StackFit.expand,
children: <Widget>[...previousChildren, ?currentChild],
);
}
+9 -156
View File
@@ -219,162 +219,15 @@ class LocalDataState {
);
}
static LocalDataState seed() {
return LocalDataState(
people: <PersonProfile>[
PersonProfile(
id: 'p-ava',
name: 'Ava Hart',
relationship: 'Partner',
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 empty() {
return const LocalDataState(
people: <PersonProfile>[],
moments: <RelationshipMoment>[],
ideas: <RelationshipIdea>[],
reminders: <ReminderRule>[],
tasks: <DashboardTask>[],
);
}
static LocalDataState seed() => empty();
}
+1 -1
View File
@@ -33,7 +33,7 @@ class AppConfig {
/// Enables periodic startup/resume sync triggers while authenticated.
static bool get enableBackgroundSync =>
const bool.fromEnvironment('ENABLE_BACKGROUND_SYNC', defaultValue: true);
const bool.fromEnvironment('ENABLE_BACKGROUND_SYNC', defaultValue: false);
/// Enables local notifications runtime for reminder delivery.
static bool get enableLocalNotifications => const bool.fromEnvironment(
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:math' as math;
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
class AnonymizedLlmDigestContext {
@@ -22,11 +23,13 @@ class AnonymizedLlmContextBuilder {
const AnonymizedLlmContextBuilder({
this.maxPeople = 20,
this.maxSignalsPerPerson = 8,
this.maxPriorSuggestionsPerPerson = 6,
DateTime Function()? now,
}) : _now = now ?? DateTime.now;
final int maxPeople;
final int maxSignalsPerPerson;
final int maxPriorSuggestionsPerPerson;
final DateTime Function() _now;
AnonymizedLlmDigestContext build(LocalDataState state) {
@@ -66,6 +69,7 @@ class AnonymizedLlmContextBuilder {
'Do not infer or ask for names.',
'Return JSON only.',
'Prefer practical suggestions that can be reviewed before saving.',
'Do not repeat priorSuggestions for the same personToken.',
],
'limits': <String, dynamic>{
'maxItems': 10,
@@ -184,9 +188,27 @@ class AnonymizedLlmContextBuilder {
.map((PersonPreferenceSignal signal) => 'avoid ${signal.label}')
.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) {
final String normalized = value.toLowerCase();
if (_containsAny(normalized, <String>[
@@ -391,11 +391,11 @@ class _PersonDetail extends ConsumerWidget {
]
: sharedMessages
.take(compact ? 3 : 5)
.map(
.map<Widget>(
(SharedMessageEntry entry) => _PeopleInsightRow(
title:
entry.sourceDisplayName?.trim().isNotEmpty == true
? entry.sourceDisplayName!.trim()
? 'Detected in text: ${entry.sourceDisplayName!.trim()}'
: 'Shared from ${entry.sourceApp}',
subtitle: entry.messageText.trim().isEmpty
? 'Imported message content is empty'
@@ -404,6 +404,23 @@ class _PersonDetail extends ConsumerWidget {
'${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),
),
],
@@ -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(
BuildContext context, {
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/local/local_models.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_models.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),
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 {
@@ -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 {
const _LlmConfigDraft({
required this.provider,
@@ -39,12 +39,7 @@ Future<SharedMessageIngestResult?> startShareCaptureReview(
required WidgetRef ref,
required SharedPayload payload,
}) {
return showShareCaptureReviewSheet(
context,
ref: ref,
payload: payload,
initialPersonId: ref.read(selectedPersonIdProvider),
);
return showShareCaptureReviewSheet(context, ref: ref, payload: payload);
}
void openShareCaptureDestination(
@@ -73,10 +68,10 @@ void openShareCaptureDestination(
String shareCaptureResultMessage(SharedMessageIngestResult result) {
if (result.isQueuedForResolution) {
return 'Saved shared capture to inbox.';
return 'Saved shared text to Capture inbox.';
}
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:relationship_saver/core/config/app_config.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_models.dart';
@@ -23,6 +24,7 @@ class IncomingShareListener extends ConsumerStatefulWidget {
class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> {
StreamSubscription<List<SharedMediaFile>>? _mediaSubscription;
String? _lastToken;
final ShareIntakeDebugLog _debugLog = const ShareIntakeDebugLog();
@override
void initState() {
@@ -41,25 +43,44 @@ class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> {
Future<void> _start() async {
if (!AppConfig.enableWhatsAppShareIntake || kIsWeb) {
await _debugLog.record(
'listener_skipped',
'enabled=${AppConfig.enableWhatsAppShareIntake}, kIsWeb=$kIsWeb',
);
return;
}
try {
await _debugLog.record('listener_start', 'Subscribing to share intents.');
_mediaSubscription = ReceiveSharingIntent.instance
.getMediaStream()
.listen(
(List<SharedMediaFile> files) =>
_handleMediaList(files, autoOpenDestination: false),
onError: (_) {},
(List<SharedMediaFile> files) {
unawaited(
_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
.instance
.getInitialMedia();
await _debugLog.record(
'initial_media',
'Received ${initialMedia.length} initial shared item(s).',
);
if (initialMedia.isNotEmpty) {
await _handleMediaList(initialMedia, autoOpenDestination: true);
}
} catch (_) {
} catch (error) {
await _debugLog.record('listener_error', '$error');
// Unsupported platform/plugin state should not break app usage.
}
}
@@ -69,8 +90,16 @@ class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> {
required bool autoOpenDestination,
}) async {
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);
if (rawText == null) {
await _debugLog.record(
'extract_skip',
'No usable text for type=${file.type.name}.',
);
continue;
}
await _handleRawText(
@@ -129,12 +158,21 @@ class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> {
sourceAppHint: sourceAppHint,
);
if (payload == null) {
await _debugLog.record(
'payload_skip',
'Parser returned null for rawLength=${trimmed.length}.',
);
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 =
'${payload.sourceApp}:${payload.sourceUserId ?? payload.sourceDisplayName ?? ''}:${payload.rawText}:${payload.url ?? ''}';
if (_lastToken == token) {
await _debugLog.record('payload_duplicate', 'Ignored duplicate share.');
return;
}
_lastToken = token;
@@ -149,8 +187,17 @@ class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> {
);
if (!mounted || result == null) {
await _debugLog.record('review_cancelled', 'Share review closed.');
return;
}
unawaited(
_debugLog.record(
'review_result',
result.isQueuedForResolution
? 'Queued for inbox.'
: 'Saved to profile; createdProfile=${result.createdProfile}.',
),
);
if (autoOpenDestination) {
openShareCaptureDestination(context, ref: ref, result: result);
@@ -88,12 +88,12 @@ class _ShareCaptureReviewSheetState
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Save Shared Capture',
'Save Shared Text',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 6),
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(
color: AppTheme.textSecondary,
),
@@ -101,10 +101,7 @@ class _ShareCaptureReviewSheetState
const SizedBox(height: 14),
_PayloadPreview(payload: widget.payload),
const SizedBox(height: 14),
Text(
'What kind of info is this?',
style: Theme.of(context).textTheme.titleMedium,
),
Text('Type', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Wrap(
spacing: 8,
@@ -344,9 +341,7 @@ class _ShareCaptureReviewSheetState
await showDialog<_CreatePersonFromShareDraft>(
context: context,
builder: (BuildContext context) => _CreatePersonFromShareDialog(
initialName:
widget.payload.sourceDisplayName ??
widget.payload.rawText.split(RegExp(r'\s+')).take(2).join(' '),
detectedName: widget.payload.sourceDisplayName,
),
);
if (draft == null) {
@@ -445,7 +440,7 @@ class _PayloadPreview extends StatelessWidget {
if (payload.sourceDisplayName != null) ...<Widget>[
const SizedBox(height: 4),
Text(
'Sender: ${payload.sourceDisplayName}',
'Detected in text: ${payload.sourceDisplayName}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
@@ -484,9 +479,9 @@ class _CreatePersonFromShareDraft {
}
class _CreatePersonFromShareDialog extends StatefulWidget {
const _CreatePersonFromShareDialog({required this.initialName});
const _CreatePersonFromShareDialog({this.detectedName});
final String initialName;
final String? detectedName;
@override
State<_CreatePersonFromShareDialog> createState() =>
@@ -504,7 +499,7 @@ class _CreatePersonFromShareDialogState
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.initialName);
_nameController = TextEditingController();
}
@override
@@ -523,9 +518,21 @@ class _CreatePersonFromShareDialogState
child: Column(
mainAxisSize: MainAxisSize.min,
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(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'),
decoration: const InputDecoration(
labelText: 'Person name',
hintText: 'Enter the real contact',
),
),
const SizedBox(height: 10),
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_repository.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';
/// Review unresolved shared messages and map them to a person profile.
@@ -45,13 +47,25 @@ class _ShareInboxViewState extends ConsumerState<ShareInboxView> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Share Inbox',
style: Theme.of(context).textTheme.headlineMedium,
Wrap(
spacing: 12,
runSpacing: 10,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
Text(
'Capture',
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),
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(
color: AppTheme.textSecondary,
),
@@ -59,7 +73,9 @@ class _ShareInboxViewState extends ConsumerState<ShareInboxView> {
const SizedBox(height: 14),
Expanded(
child: localState.sharedInbox.isEmpty
? _EmptyInbox(compact: compact)
? SingleChildScrollView(
child: _EmptyInbox(compact: compact),
)
: ListView.separated(
itemCount: localState.sharedInbox.length,
separatorBuilder: (_, _) =>
@@ -178,13 +194,8 @@ class _ShareInboxViewState extends ConsumerState<ShareInboxView> {
}
final _CreateProfileDraft? draft = await showDialog<_CreateProfileDraft>(
context: context,
builder: (BuildContext context) => _CreateProfileDialog(
initialName:
entry.sourceDisplayName ??
entry.sourceUserId ??
entry.sourceThreadId ??
'',
),
builder: (BuildContext context) =>
_CreateProfileDialog(detectedName: entry.sourceDisplayName),
);
if (draft == null || !mounted) {
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 {
setState(() {
_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 {
const _CandidateReview({
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 {
const _EmptyInbox({required this.compact});
@@ -1103,7 +1210,7 @@ class _EmptyInbox extends StatelessWidget {
),
const SizedBox(height: 6),
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(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
@@ -1132,9 +1239,9 @@ class _CreateProfileDraft {
}
class _CreateProfileDialog extends StatefulWidget {
const _CreateProfileDialog({required this.initialName});
const _CreateProfileDialog({this.detectedName});
final String initialName;
final String? detectedName;
@override
State<_CreateProfileDialog> createState() => _CreateProfileDialogState();
@@ -1149,7 +1256,7 @@ class _CreateProfileDialogState extends State<_CreateProfileDialog> {
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.initialName);
_nameController = TextEditingController();
_relationshipController = TextEditingController(text: 'Contact');
_locationController = TextEditingController();
_aliasesController = TextEditingController();
@@ -1172,9 +1279,21 @@ class _CreateProfileDialogState extends State<_CreateProfileDialog> {
child: Column(
mainAxisSize: MainAxisSize.min,
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(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'),
decoration: const InputDecoration(
labelText: 'Person name',
hintText: 'Enter the real contact',
),
),
const SizedBox(height: 8),
TextField(
@@ -97,21 +97,9 @@ class BackendGatewayFake implements BackendGateway {
@override
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(
cursor: 'fake-pull-cursor-1',
changes: changes.take(limit).toList(),
cursor: cursor ?? 'fake-pull-cursor-empty',
changes: const <ChangeEnvelope>[],
);
}
@@ -21,10 +21,7 @@ void main() {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final LocalDataState initial = await container.read(
localRepositoryProvider.future,
);
final String personId = initial.people.first.id;
final String personId = await _addTestPerson(container);
await container
.read(localRepositoryProvider.notifier)
@@ -65,10 +62,10 @@ void main() {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final String personId = await _addTestPerson(container);
final LocalDataState initial = await container.read(
localRepositoryProvider.future,
);
final String personId = initial.people.first.id;
final int ideaCount = initial.ideas.length;
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: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/domain/ai_digest_models.dart';
import 'package:relationship_saver/features/dashboard/domain/dashboard_models.dart';
import 'package:relationship_saver/features/ideas/domain/idea_models.dart';
import 'package:relationship_saver/features/moments/domain/moment_models.dart';
@@ -73,6 +74,19 @@ void main() {
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(
@@ -90,5 +104,9 @@ void main() {
expect(prompt, isNot(contains('thread-secret')));
expect(prompt, isNot(contains('Raw private chat text')));
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);
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);
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.pumpAndSettle(const Duration(milliseconds: 450));
expect(find.text('Dashboard'), findsWidgets);
expect(find.text('Capture'), findsWidgets);
await tester.tap(find.text('Settings').first);
await tester.pumpAndSettle(const Duration(milliseconds: 450));
@@ -47,7 +47,7 @@ void main() {
await tester.tap(find.widgetWithText(FilledButton, 'Sign In'));
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.pumpAndSettle(const Duration(milliseconds: 500));
@@ -91,13 +91,5 @@ void main() {
}),
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);
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(
localRepositoryProvider.future,
);
final String personId = seeded.people.first.id;
await container
.read(localRepositoryProvider.notifier)
.upsertInferredPreferenceSignalObservation(
personId: seeded.people.first.id,
personId: personId,
key: 'drink:tea',
label: 'Tea',
category: 'drink',
@@ -58,7 +68,7 @@ void main() {
expect(find.text('Explore'), findsOneWidget);
final Finder avaNode = find.byKey(
const ValueKey<String>('graph-node-p-ava'),
ValueKey<String>('graph-node-$personId'),
);
expect(avaNode, findsOneWidget);
+92 -32
View File
@@ -17,15 +17,17 @@ void main() {
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();
addTearDown(container.dispose);
final state = await container.read(localRepositoryProvider.future);
expect(state.people, isNotEmpty);
expect(state.moments, isNotEmpty);
expect(state.tasks, isNotEmpty);
expect(state.people, isEmpty);
expect(state.moments, isEmpty);
expect(state.ideas, isEmpty);
expect(state.reminders, isEmpty);
expect(state.tasks, isEmpty);
});
test('adds and removes person with persistence state update', () async {
@@ -62,8 +64,8 @@ void main() {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final String personId = await _addTestPerson(container);
final state = await container.read(localRepositoryProvider.future);
final String personId = state.people.first.id;
final int beforeCount = state.moments.length;
await container
@@ -86,10 +88,7 @@ void main() {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final LocalDataState state = await container.read(
localRepositoryProvider.future,
);
final String personId = state.people.first.id;
final String personId = await _addTestPerson(container);
final PersonPreferenceSignal first = await container
.read(localRepositoryProvider.notifier)
@@ -161,7 +160,7 @@ void main() {
});
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 {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
@@ -184,22 +183,35 @@ void main() {
final LocalDataState afterFirst = await container.read(
localRepositoryProvider.future,
);
expect(first.createdProfile, isTrue);
expect(afterFirst.people.length, before.people.length + 1);
final SourceProfileLink createdLink = afterFirst.sourceLinks.firstWhere(
(SourceProfileLink link) => link.profileId == first.profileId,
expect(first.isQueuedForResolution, isTrue);
expect(first.createdProfile, isFalse);
expect(afterFirst.people.length, before.people.length);
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(
afterFirst.sourceLinks.any(
(SourceProfileLink link) => link.profileId == first.profileId,
),
isTrue,
);
expect(
afterFirst.moments.any(
afterCreate.moments.any(
(RelationshipMoment moment) =>
moment.personId == first.profileId && moment.type == 'whatsapp',
moment.personId == created.profileId && moment.type == 'whatsapp',
),
isTrue,
);
@@ -219,11 +231,11 @@ void main() {
localRepositoryProvider.future,
);
expect(second.createdProfile, isFalse);
expect(second.profileId, first.profileId);
expect(afterSecond.people.length, afterFirst.people.length);
expect(second.profileId, created.profileId);
expect(afterSecond.people.length, afterCreate.people.length);
expect(
afterSecond.sharedMessages.length,
afterFirst.sharedMessages.length + 1,
afterCreate.sharedMessages.length + 1,
);
},
);
@@ -234,7 +246,7 @@ void main() {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
await container.read(localRepositoryProvider.future);
await _addTestPerson(container, name: 'Nora Diaz');
final SharedMessageIngestResult result = await container
.read(localRepositoryProvider.notifier)
@@ -285,10 +297,7 @@ void main() {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final LocalDataState state = await container.read(
localRepositoryProvider.future,
);
final String personId = state.people.first.id;
final String personId = await _addTestPerson(container);
final SharedPayload payload = SharePayloadParser.parse(
rawText: 'She loves vegan sushi and rooftop dinners.',
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(
'queues near-match conflict and then uses fingerprint mapping for follow-up shares',
() async {
@@ -689,10 +727,10 @@ void main() {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final String personId = await _addTestPerson(container);
final LocalDataState state = await container.read(
localRepositoryProvider.future,
);
final String personId = state.people.first.id;
final int beforeCount = state.ideas.length;
await container
@@ -740,10 +778,10 @@ void main() {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final String personId = await _addTestPerson(container);
final LocalDataState state = await container.read(
localRepositoryProvider.future,
);
final String personId = state.people.first.id;
final int beforeCount = state.reminders.length;
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 {
final List<List<ReminderRule>> snapshots = <List<ReminderRule>>[];
@@ -19,7 +19,7 @@ void main() {
addTearDown(container.dispose);
final initial = await container.read(localRepositoryProvider.future);
expect(initial.people, isNotEmpty);
expect(initial.people, isEmpty);
await container
.read(localRepositoryProvider.notifier)
@@ -30,6 +30,15 @@ void main() {
);
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(
localRepositoryProvider.future,
);
@@ -65,7 +65,7 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.text('Share Inbox'), findsOneWidget);
expect(find.text('Capture'), findsOneWidget);
expect(find.text('Ambiguous match'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Review & Save'), findsOneWidget);
@@ -10,7 +10,7 @@ import 'package:relationship_saver/features/sync/sync_coordinator.dart';
void main() {
testWidgets(
'triggers sync when reachability transitions from offline to online',
'does not trigger sync when background sync is disabled by default',
(WidgetTester tester) async {
final _TestReachability reachability = _TestReachability(initial: false);
addTearDown(reachability.dispose);
@@ -39,56 +39,56 @@ void main() {
);
await tester.pumpAndSettle(const Duration(milliseconds: 60));
// Startup trigger always runs once when background sync is enabled.
expect(syncCalls, 1);
expect(syncCalls, 0);
reachability.emit(false);
await tester.pumpAndSettle(const Duration(milliseconds: 40));
expect(syncCalls, 1);
expect(syncCalls, 0);
reachability.emit(true);
await tester.pumpAndSettle(const Duration(milliseconds: 60));
expect(syncCalls, 2);
expect(syncCalls, 0);
},
);
testWidgets('does not trigger extra sync while connectivity stays online', (
WidgetTester tester,
) async {
final _TestReachability reachability = _TestReachability(initial: true);
addTearDown(reachability.dispose);
testWidgets(
'does not trigger sync while connectivity stays online by default',
(WidgetTester tester) async {
final _TestReachability reachability = _TestReachability(initial: true);
addTearDown(reachability.dispose);
int syncCalls = 0;
int syncCalls = 0;
await tester.pumpWidget(
ProviderScope(
overrides: [
networkReachabilityProvider.overrideWithValue(reachability),
syncCoordinatorProvider.overrideWith(
(Ref ref) => _TestSyncCoordinator(
ref,
onSyncNow: () async {
syncCalls += 1;
return _okResult();
},
await tester.pumpWidget(
ProviderScope(
overrides: [
networkReachabilityProvider.overrideWithValue(reachability),
syncCoordinatorProvider.overrideWith(
(Ref ref) => _TestSyncCoordinator(
ref,
onSyncNow: () async {
syncCalls += 1;
return _okResult();
},
),
),
],
child: const Directionality(
textDirection: TextDirection.ltr,
child: SyncBackgroundRunner(child: SizedBox()),
),
],
child: const Directionality(
textDirection: TextDirection.ltr,
child: SyncBackgroundRunner(child: SizedBox()),
),
),
);
await tester.pumpAndSettle(const Duration(milliseconds: 60));
);
await tester.pumpAndSettle(const Duration(milliseconds: 60));
expect(syncCalls, 1);
expect(syncCalls, 0);
reachability.emit(true);
await tester.pumpAndSettle(const Duration(milliseconds: 60));
reachability.emit(true);
await tester.pumpAndSettle(const Duration(milliseconds: 60));
expect(syncCalls, 1);
});
expect(syncCalls, 0);
},
);
}
class _TestSyncCoordinator extends SyncCoordinator {
@@ -111,6 +111,29 @@ void main() {
);
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 {
@@ -23,6 +23,15 @@ void main() {
);
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 person = localBefore.people.first;