Author SHA1 Message Date
jjbrunton 5ebf57bdbf Merge branch 'staging' into task/ios-export-compliance 2026-08-30 10:07:10 +01:00
jjbrunton 089e629021 docs(agents): delegate substantive repository tasks 2026-08-30 10:06:37 +01:00
jjbrunton 58b70710db chore(ios): declare exempt encryption 2026-08-30 09:58:26 +01:00
jjbrunton d645d83c47 chore(ios): record v1.0.6 build 44 2026-08-30 01:29:55 +01:00
jjbrunton 179f912170 chore(android): record v1.0.6 build 48 2026-08-30 01:05:13 +01:00
jjbrunton a604f82110 chore(release): prepare v1.0.6 2026-08-30 00:46:46 +01:00
jjbrunton ae8e72ddb5 Merge pull request #10 from jjbrunton/staging
chore(release): complete mobile release configuration
2026-08-30 00:41:09 +01:00
jjbrunton 2e4cddd1c8 fix(mobile): restore iOS SDK 54 runtime 2026-08-30 00:29:45 +01:00
jjbrunton 440f224095 docs(release): require upload confirmation 2026-08-30 00:28:14 +01:00
jjbrunton 22871d037c chore(release): configure store submissions
Route Android uploads to an internal draft and bind iOS submissions to the verified App Store Connect app. Document explicit-path local artifact submission and preserve separate release authorization.
2026-08-30 00:13:40 +01:00
jjbrunton fd38b303f0 Merge pull request #9 from jjbrunton/staging
fix(api): keep auth sync timestamps monotonic
2026-08-29 23:57:39 +01:00
jjbrunton 266233a473 fix(api): keep auth sync timestamps monotonic
Use PostgreSQL's clock for both the initial auth sync timestamp and later identity refreshes so host clock skew cannot move the marker backwards.
2026-08-29 23:45:39 +01:00
jjbrunton 387b242aea Merge pull request #8 from jjbrunton/staging
chore(release): promote staging for v1.0.6
2026-08-29 23:34:30 +01:00
jjbrunton 2bc69c2790 feat(mobile): surface pending questions
Add a conditional Home cue for partner-answered questions and route both the card and catch-up reminders into the pending answer flow. Track prompt engagement and cover hidden, visible, CTA, and notification behavior.
2026-08-29 23:24:19 +01:00
jjbrunton 9da44217f7 feat(release): add generation workflow
Add a guarded mobile release skill that synchronizes public versions, writes on-brand notes, verifies signed iOS and Android artifacts, and tags the verified release commit.
2026-08-29 23:22:35 +01:00
31 changed files with 3396 additions and 1430 deletions

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
---
name: generate-release
description: Generate a signed Sauci mobile release by bumping the public version, writing on-brand release notes, building and verifying iOS and Android binaries, committing release metadata, and tagging the release commit. Use for an actual production mobile release; use release-mobile for preparation, one-platform builds, uploads, or submission work that does not require the complete release lifecycle.
---
# Generate release
Create a reproducible release whose tag identifies the reviewed release source
and final platform build metadata. Read `apps/mobile/AGENTS.md`,
`docs/releasing.md`, and the `release-mobile` and `commit` skills before acting. Read
[references/release-notes-voice.md](references/release-notes-voice.md) before
drafting notes and [references/artifact-verification.md](references/artifact-verification.md)
before accepting a build.
A request to generate or create a release authorizes the scoped version edits,
release notes, local builds, release commits, and one local annotated tag. It
does not authorize pushing commits or tags, uploading binaries, submitting a
store release, changing signing credentials, or regenerating native projects.
## Defaults
- Build both iOS and Android production binaries unless the user narrows the
platforms.
- Increment the patch version unless the user specifies `minor`, `major`, or an
exact SemVer version.
- Use `v<version>` for the annotated Git tag.
- Write artifacts beneath ignored `dist/releases/v<version>/`; never commit
binaries, credentials, logs, or build directories.
## Preflight
Fetch the remote and inspect the worktree, branch, upstream, existing tags,
toolchains, EAS authentication, and signing availability without changing them.
Require all of the following:
- a named branch, normally `main` for a production release;
- a clean starting worktree with `HEAD` equal to its fetched upstream;
- the previous reachable `v[0-9]*` release tag and no collision for the next tag;
- consistent public versions across `app.json`, Android `versionName`, and every
iOS `MARKETING_VERSION` (`scripts/set_version.mjs --check`);
- Node 20, JDK 17 for Android, and Xcode/CocoaPods for iOS;
- the configured production API/Auth/RevenueCat environment, without printing
secret values.
Do not switch branches, discard work, repair credentials, push, or weaken branch
protection merely to pass preflight. Stop with the exact blocker. Never run
`expo prebuild` as part of this workflow.
## Prepare the release
1. Derive the next version with `scripts/set_version.mjs --bump <kind>` or set an
explicit version with `--version <x.y.z>`. The script updates the checked-in
public-version sources but deliberately leaves platform build numbers to EAS.
2. Review the complete first-parent and ordinary commit diff from the previous
reachable release tag to the starting commit. Inspect the actual product diff
for every user-facing claim; commit subjects alone are not evidence.
3. Add the new entry immediately below the title in `docs/release-notes.md`.
Describe user-visible value, meaningful fixes, privacy or reliability changes,
and only claims supported by the diff. Omit internal migrations, refactors,
CI, infrastructure, dependency churn, and implementation jargon unless the
user experiences a direct result. Use a temporary `Build numbers pending
artifact verification` line until the binaries have been inspected; never
guess or predict build numbers.
4. Run `npm run verify:full` and the native acceptance required by the
`release-mobile` skill. A missing prerequisite is a failure/limitation, not a
pass.
5. Review the scoped diff and create `chore(release): prepare v<version>`. Do not
stage unrelated paths. This clean release-preparation commit must descend
directly from the fetched upstream commit.
## Build and capture platform metadata
Build from the clean prepared source using the package scripts in
`apps/mobile/package.json`, with `EAS_LOCAL_BUILD_ARTIFACTS_DIR` set to the
versioned artifact directory. When building both platforms, build Android first,
then iOS.
After each build:
1. Inspect the worktree before doing anything else. Accept only the documented
EAS build-number changes in `app.json`, that platform's native version source,
and the iOS widget plist when applicable. Treat any implementation, resource,
dependency, or native-project regeneration as a failed build.
2. Independently inspect the binary for package/bundle identifier, public
version, platform build number, production endpoint configuration where
inspectable, and signing identity/fingerprint. Require the embedded public
version to equal the requested version.
3. Record the verified platform build number in the new release-note entry.
4. Commit only the expected metadata and release-note update as
`chore(<platform>): record v<version> build <number>`. The worktree must be
clean before starting the next platform build.
Do not accept a successful EAS exit code as proof of a valid artifact. If either
requested platform fails, do not tag a partial release.
## Tag and prove the result
Re-run focused version checks and `npm run verify:fast`. Confirm every requested
artifact exists, has a SHA-256 digest, passed independent version/signature
inspection, and was produced from the release commit chain. Require a clean
worktree, then create the annotated local tag `v<version>` at `HEAD`.
Read back the tag object and prove:
- the tag resolves to `HEAD`;
- the tag version equals all public-version sources and embedded artifacts;
- recorded build numbers equal the embedded platform build numbers;
- no binary or ignored build output entered the commit;
- the release commits descend from the fetched starting branch commit;
- for a two-platform release, the Android artifact's source commit differs from
the final tag only by the later verified iOS build metadata.
Report the branch, tag, commit, release-note summary, artifact paths, versions,
build numbers, signatures, SHA-256 digests, checks run, and any environmental
limitations. State explicitly that the tag and commits remain local unless a
separate authorized push was completed.
@@ -0,0 +1,4 @@
interface:
display_name: "Generate Release"
short_description: "Bump, build, note, and tag Sauci releases"
default_prompt: "Use $generate-release to create the next signed Sauci mobile release for iOS and Android."
@@ -0,0 +1,39 @@
# Release artifact verification
Use the strongest locally available platform tools and record the commands and
results. Never print provisioning profiles, keys, tokens, or environment values.
## Android AAB
Require a production `.aab`. Inspect its manifest with `bundletool dump manifest`
or Android SDK `apkanalyzer`, and verify:
- package `com.sauci.app`;
- `versionName` equals the release version;
- `versionCode` equals the recorded Android build number.
Verify the archive signature with `jarsigner -verify -verbose -certs`. Compare the
signing certificate SHA-1 to the production fingerprint documented in
`docs/releasing.md`; do not copy credentials into commands or reports. Calculate
the artifact SHA-256 with `shasum -a 256`.
## iOS IPA
Extract the `.ipa` into a new temporary directory created with `mktemp -d`. Read
the application `Info.plist` with `/usr/libexec/PlistBuddy` or `plutil` and verify:
- `CFBundleIdentifier` is `com.sauci.app`;
- `CFBundleShortVersionString` equals the release version;
- `CFBundleVersion` equals the recorded iOS build number.
Use `codesign -dvvv` and `codesign --verify --deep --strict` on the extracted app
to inspect and verify signing without exposing the embedded profile. Calculate the
original `.ipa` SHA-256 with `shasum -a 256`. Remove only the explicit temporary
extraction directory after inspection.
## Source correspondence
For both platforms, retain the build command, artifact path, build completion
time, and digest in the final report. Compare the post-build tracked diff with the
documented allowed version files. A binary with the right version but unexpected
source regeneration, wrong signing, or an unverifiable manifest is not accepted.
@@ -0,0 +1,51 @@
# Sauci release-note voice
Sauci sounds warm, confident, inviting, and quietly playful. It is a premium
couples app about private connection and shared curiosity: supportive without
being clinical, polished without sounding corporate, and intimate without being
graphic or presumptuous.
## Shape
Use a short opening sentence followed by three to six compact bullets. Lead with
the change that most improves a couple's experience. Combine related work into
one benefit rather than mirroring the commit list. Include a brief closing line
only when it adds warmth or useful context.
In `docs/release-notes.md`, use:
```markdown
## v1.2.3 — 29 August 2026
_iOS build 44 · Android build 48_
A little more ease, clarity, and connection in every check-in.
- **Closer conversations** — Find the prompts that feel right for both of you.
- **Smoother moments** — Chats and matches now feel quicker and more reliable.
- **More control** — Clearer choices help keep your shared space comfortable.
```
Adapt the words to the evidence; do not reuse this copy when those claims are not
present in the release. If only one platform was requested, record only its build.
## Language
- Address the couple's experience with `you`, `your partner`, `together`, or
`your shared space` where natural.
- Prefer concrete benefits: `pick up where you left off`, `find the right prompt`,
`keep conversations flowing`, `stay in control`.
- Use restrained warmth. One gentle phrase is enough; avoid forced innuendo,
breathless hype, exclamation marks, emoji, and claims that Sauci will improve or
fix a relationship.
- Prefer `new`, `smoother`, `clearer`, `more private`, and `more reliable` to
`revolutionary`, `game-changing`, `massive`, or `best ever`.
- Explain a fix as the experience restored, not the defect mechanics. Name a
security or privacy change plainly when users need to understand it.
## Evidence boundary
Release notes describe shipped behavior only. Do not claim a feature from a plan,
flagged-off path, migration, test, or commit title without confirming the runtime
path in the diff and release configuration. Do not expose internal service names,
database details, moderation logic, security-sensitive implementation, or content
that the product intentionally keeps private between partners.
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "../../../..");
const files = {
app: path.join(repoRoot, "apps/mobile/app.json"),
android: path.join(repoRoot, "apps/mobile/android/app/build.gradle"),
ios: path.join(repoRoot, "apps/mobile/ios/Sauci.xcodeproj/project.pbxproj"),
};
const semverPattern = /^([0-9]+)\.([0-9]+)\.([0-9]+)$/;
function fail(message) {
console.error(`set_version: ${message}`);
process.exit(1);
}
function parseVersion(value, label) {
const match = semverPattern.exec(value);
if (!match) fail(`${label} must be an exact numeric SemVer version (x.y.z)`);
return match.slice(1).map(Number);
}
function readState() {
const appRaw = fs.readFileSync(files.app, "utf8");
const androidRaw = fs.readFileSync(files.android, "utf8");
const iosRaw = fs.readFileSync(files.ios, "utf8");
const appVersion = JSON.parse(appRaw).expo?.version;
const androidVersions = [...androidRaw.matchAll(/\bversionName\s+["']([^"']+)["']/g)].map(
(match) => match[1],
);
const iosVersions = [...iosRaw.matchAll(/\bMARKETING_VERSION\s*=\s*([^;]+);/g)].map(
(match) => match[1].trim(),
);
if (typeof appVersion !== "string") fail("apps/mobile/app.json has no expo.version");
if (androidVersions.length !== 1) fail("expected exactly one Android versionName");
if (iosVersions.length === 0) fail("found no iOS MARKETING_VERSION values");
const versions = [appVersion, ...androidVersions, ...iosVersions];
versions.forEach((version) => parseVersion(version, `configured version ${version}`));
if (new Set(versions).size !== 1) {
fail(`public versions disagree: ${versions.join(", ")}`);
}
return { appRaw, androidRaw, iosRaw, current: appVersion, iosCount: iosVersions.length };
}
function nextVersion(current, bump) {
const [major, minor, patch] = parseVersion(current, "current version");
if (bump === "major") return `${major + 1}.0.0`;
if (bump === "minor") return `${major}.${minor + 1}.0`;
if (bump === "patch") return `${major}.${minor}.${patch + 1}`;
fail("--bump must be patch, minor, or major");
}
const args = process.argv.slice(2);
const check = args.length === 1 && args[0] === "--check";
const bumpIndex = args.indexOf("--bump");
const versionIndex = args.indexOf("--version");
if (!check && (bumpIndex === -1) === (versionIndex === -1)) {
fail("use --check, --bump patch|minor|major, or --version x.y.z");
}
const state = readState();
if (check) {
console.log(`public version ${state.current} is consistent across app.json, Android, and ${state.iosCount} iOS settings`);
process.exit(0);
}
const desired =
bumpIndex !== -1
? nextVersion(state.current, args[bumpIndex + 1])
: (parseVersion(args[versionIndex + 1], "--version"), args[versionIndex + 1]);
if (desired === state.current) fail(`requested version is already ${state.current}`);
const currentParts = parseVersion(state.current, "current version");
const desiredParts = parseVersion(desired, "requested version");
const increases = desiredParts.some(
(part, index) => part > currentParts[index] && desiredParts.slice(0, index).every((value, prior) => value === currentParts[prior]),
);
if (!increases) fail(`requested version ${desired} must be greater than ${state.current}`);
const appRaw = state.appRaw.replace(
/(\"version\"\s*:\s*\")([^\"]+)(\")/,
`$1${desired}$3`,
);
const androidRaw = state.androidRaw.replace(
/(\bversionName\s+["'])([^"']+)(["'])/,
`$1${desired}$3`,
);
const iosRaw = state.iosRaw.replace(
/(\bMARKETING_VERSION\s*=\s*)([^;]+)(;)/g,
`$1${desired}$3`,
);
fs.writeFileSync(files.app, appRaw);
fs.writeFileSync(files.android, androidRaw);
fs.writeFileSync(files.ios, iosRaw);
console.log(`updated public version ${state.current} -> ${desired}`);
+43 -8
View File
@@ -10,14 +10,49 @@ scoped instructions. Confirm target platform and release profile. Run repository
verification plus a native acceptance pass before starting a release build.
Before a build from the current upstream branch, fetch, require a clean worktree,
and prove `HEAD` matches its upstream. Sauci tracks its native iOS and Android
projects, so routine release builds must package those checked-in sources. Do not
run `expo prebuild` first; native regeneration is a separate reviewed change.
and prove `HEAD` matches its upstream. The `generate-release` skill may instead
build a clean, scoped release-preparation commit that descends directly from that
fetched upstream; between its multi-platform builds it commits only independently
verified EAS version metadata. Sauci tracks its native iOS and Android projects,
so routine release builds must package those checked-in sources. Do not run
`expo prebuild` first; native regeneration is a separate reviewed change.
Use the package scripts in `apps/mobile/package.json`; they create artifacts only.
Never infer upload or submission authority from build authorization. Store upload
uses a separately authorized command. Preserve platform signing and local EAS
versioning. After a build, verify that tracked changes are limited to the expected
version metadata, then independently read the artifact version and signature.
Report the artifact, version/build number, upload result when authorized, and
read-back state.
uses a separately authorized command.
## Upload authorization gate
After an artifact passes verification, stop immediately before the external
upload and ask the user for confirmation. The request must identify:
- the platform, exact artifact path, bundle/package identifier, public version,
build number, signature identity, and SHA-256 digest;
- the exact destination and resulting state: Google Play internal-track draft or
App Store Connect/TestFlight processing;
- that the upload does not authorize a public rollout or App Review submission.
For iOS, inspect the current App Store Connect automatic-distribution behavior
before asking. If the build would be distributed automatically, name the exact
TestFlight groups or testers in the request and include that effect in the fresh
authorization. If the effect cannot be established, stop; changing an automatic-
distribution setting also requires separate authorization.
Even when the original request includes building, releasing, uploading, or
submitting, require this fresh confirmation once the exact verified artifact and
destination are known. End the request with: `Upload this verified artifact to
<destination> now?` A clear affirmative response authorizes one upload of that
artifact to that destination, plus only any automatic TestFlight distribution
explicitly disclosed in the request. Run `eas submit` with the explicit
`--platform`, `--profile production`, `--path`, and `--wait` arguments; never
select `--latest`. If the result is ambiguous, inspect EAS and the store before
doing anything else, do not retry, and ask again before another upload.
Apply this gate to every upload method, including EAS, Fastlane, Xcode Organizer,
and the store consoles. A later public rollout, App Review submission, or
additional TestFlight distribution requires another fresh authorization.
Preserve platform signing and local EAS versioning. After a build, verify that
tracked changes are limited to the expected version metadata, then independently
read the artifact version and signature. Report the artifact, version/build
number, upload result when authorized, and store read-back state.
+9 -3
View File
@@ -25,9 +25,15 @@ MCP service in one npm/Turborepo workspace.
and prove the relevant compatibility contracts still hold. Preserve deployed
interfaces and supported client behavior; repository checks alone do not
prove a live change is safe.
10. Scope work to the task. Route bounded implementation through the project
`implementation` agent when delegation is useful; the primary agent retains
acceptance, review, and final verification. Do not delegate trivial edits.
10. Delegate every substantive repository task to at least one project agent so
detailed exploration or execution stays out of the primary context.
"Substantive" includes repository exploration, multi-file reasoning,
implementation, test execution, or contract/risk analysis. Use `repo_scout`
for read-only investigation and `implementation` for bounded changes; use
the additional roles according to `docs/agents/routing.md`. The primary
agent retains scope, acceptance, review, and the final answer. Skip
delegation only for trivial conversation, a single obvious lookup, or a
mechanical one-line edit whose coordination cost exceeds the work.
11. Use high-effort reviewers only for the risk triggers in
`docs/agents/routing.md`.
+4 -1
View File
@@ -83,7 +83,10 @@ export class PostgresRepository implements ApiRepository {
target: profiles.id,
set: {
email: sql`coalesce(excluded.email, ${profiles.email})`,
authSyncedAt: new Date(),
// Keep the write on the same clock as the column default. API and
// PostgreSQL hosts can differ slightly, which must not move this
// monotonic sync marker backwards.
authSyncedAt: sql`now()`,
},
}).returning();
+2 -2
View File
@@ -92,8 +92,8 @@ android {
applicationId 'com.sauci.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 47
versionName "1.0.5"
versionCode 48
versionName "1.0.6"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
signingConfigs {
+6 -3
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Sauci",
"slug": "sauci",
"version": "1.0.5",
"version": "1.0.6",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "app.sauci",
@@ -16,12 +16,15 @@
"ios": {
"supportsTablet": false,
"bundleIdentifier": "com.sauci.app",
"config": {
"usesNonExemptEncryption": false
},
"entitlements": {
"com.apple.security.application-groups": [
"group.com.sauci.app"
]
},
"buildNumber": "43",
"buildNumber": "44",
"usesAppleSignIn": true,
"googleServicesFile": "./GoogleService-Info.plist",
"infoPlist": {
@@ -183,7 +186,7 @@
"backgroundColor": "#1a1a2e"
},
"package": "com.sauci.app",
"versionCode": 47,
"versionCode": 48,
"googleServicesFile": "./google-services.json",
"permissions": [
"RECEIVE_BOOT_COMPLETED",
+48 -10
View File
@@ -1,15 +1,17 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { ScrollView, StyleSheet, Platform, View, Text, useWindowDimensions } from 'react-native';
import { useFocusEffect } from 'expo-router';
import { useFocusEffect, useRouter } from 'expo-router';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { useAuthStore, usePacksStore, useSubscriptionStore } from '../../src/store';
import * as Haptics from 'expo-haptics';
import { useAuthStore, useMatchStore, usePacksStore, useSubscriptionStore } from '../../src/store';
import { GradientBackground } from '../../src/components/ui';
import { Paywall } from '../../src/components/paywall';
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile } from '../../src/components/discovery';
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile, PendingQuestionsCard } from '../../src/components/discovery';
import { StreakDisplay } from '../../src/components/StreakDisplay';
import { colors, spacing, typography, radius } from '../../src/theme';
import { Events } from '../../src/lib/analytics';
import { colors, spacing, typography } from '../../src/theme';
import type { QuestionPack, Category } from '../../src/types';
const MAX_CONTENT_WIDTH = 600;
@@ -69,14 +71,21 @@ function groupPacksByCategory(
export default function DiscoveryScreen() {
const { user, partner, couple } = useAuthStore();
const { packs, categories, fetchPacks, ensurePacksLoaded, getPackProgress } = usePacksStore();
const { pendingQuestions, fetchPendingQuestions } = useMatchStore();
const { subscription } = useSubscriptionStore();
const router = useRouter();
const { width } = useWindowDimensions();
const [showPaywall, setShowPaywall] = useState(false);
const [headerHeight, setHeaderHeight] = useState(130);
const lastPromptImpression = useRef<string | null>(null);
// Check all sources of premium access: user flag, partner flag, or active subscription
const isPremiumUser = user?.is_premium || partner?.is_premium || subscription.isProUser;
const isWideScreen = width > MAX_CONTENT_WIDTH;
const pendingQuestionIds = useMemo(
() => pendingQuestions.map(question => question.id).join(','),
[pendingQuestions],
);
// Load the catalog once. Returning to this tab shows what is cached; answering a
// question invalidates it, and StreakDisplay owns the streak, so neither needs a
@@ -84,7 +93,17 @@ export default function DiscoveryScreen() {
useFocusEffect(
useCallback(() => {
void ensurePacksLoaded();
}, [ensurePacksLoaded])
void fetchPendingQuestions();
if (pendingQuestionIds && lastPromptImpression.current !== pendingQuestionIds) {
Events.pendingQuestionsPromptViewed(pendingQuestions.length);
lastPromptImpression.current = pendingQuestionIds;
}
return () => {
lastPromptImpression.current = null;
};
}, [ensurePacksLoaded, fetchPendingQuestions, pendingQuestionIds, pendingQuestions.length])
);
// Group packs by category
@@ -96,6 +115,18 @@ export default function DiscoveryScreen() {
// Check if user has no packs enabled (empty state)
const hasNoPacks = packs.length === 0;
const handlePendingQuestionsPress = useCallback(() => {
const firstQuestion = pendingQuestions[0];
if (!firstQuestion) return;
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
Events.pendingQuestionsPromptTapped(pendingQuestions.length);
router.push({
pathname: '/(app)/swipe',
params: { mode: 'pending', startQuestionId: firstQuestion.question.id },
});
}, [pendingQuestions, router]);
return (
<GradientBackground>
<View
@@ -117,16 +148,23 @@ export default function DiscoveryScreen() {
showsVerticalScrollIndicator={false}
>
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
<PendingQuestionsCard
questions={pendingQuestions}
partnerName={partner?.name}
onPress={handlePendingQuestionsPress}
delay={50}
/>
{/* Shared streak - hidden entirely for couples with nothing running */}
<View style={styles.streakSlot}>
<StreakDisplay delay={50} />
<StreakDisplay delay={100} />
</View>
{/* Live Draw Activity Tile */}
<LiveDrawTile delay={100} />
<LiveDrawTile delay={150} />
{/* Dares */}
<DaresTile delay={150} />
<DaresTile delay={200} />
{/* Content Rows by Category */}
{packsByCategory.map((group, index) => (
@@ -136,7 +174,7 @@ export default function DiscoveryScreen() {
packs={group.packs}
isPremiumUser={isPremiumUser}
categoryId={group.category.id}
delay={200 + index * 100}
delay={250 + index * 100}
getPackProgress={getPackProgress}
onShowPaywall={() => setShowPaywall(true)}
/>
+1
View File
@@ -2,5 +2,6 @@ module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['react-native-worklets/plugin'],
};
};
+11 -1
View File
@@ -32,6 +32,16 @@
}
},
"submit": {
"production": {}
"production": {
"android": {
"track": "internal",
"releaseStatus": "draft",
"changesNotSentForReview": true
},
"ios": {
"ascAppId": "6757159885",
"appleTeamId": "BXVPBATDZY"
}
}
}
}
+2446 -1290
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"expo.jsEngine": "hermes",
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
"newArchEnabled": "false",
"newArchEnabled": "true",
"ios.useFrameworks": "static",
"apple.extraPods": "[]",
"apple.ccacheEnabled": "false",
+21 -16
View File
@@ -7,15 +7,14 @@
objects = {
/* Begin PBXBuildFile section */
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
5254BDADE70741A0B2504FB1 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = F744EAD4ABA54509966475D6 /* noop-file.swift */; };
7B71EEB2AF682FA7AFF556D5 /* Pods_Sauci.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DDDAD8C12C38D3BD0A76D345 /* Pods_Sauci.framework */; };
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
CEF2F278ABBC5FBFD470A0F7 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D7B5C17768E3B74AC152531F /* PrivacyInfo.xcprivacy */; };
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
FDFABE1A1A3547B1B22DD2DD /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A9280FDF3E0D43AC90382DBC /* GoogleService-Info.plist */; };
XX12B55A1EDABF083C3B51XX /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = XX470101BAEB8E0BBC8B0DXX /* WidgetKit.framework */; };
XX1A6800511A685AB346D2XX /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = XXA12889081EB894481655XX /* AppIntents.framework */; };
@@ -50,11 +49,8 @@
/* Begin PBXFileReference section */
13B07F961A680F5B00A75B9A /* Sauci.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; fileEncoding = 4; includeInIndex = 0; path = Sauci.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Sauci/AppDelegate.h; sourceTree = "<group>"; };
13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = Sauci/AppDelegate.mm; sourceTree = "<group>"; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Sauci/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sauci/Info.plist; sourceTree = "<group>"; };
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Sauci/main.m; sourceTree = "<group>"; };
6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.debug.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.debug.xcconfig"; sourceTree = "<group>"; };
7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.release.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.release.xcconfig"; sourceTree = "<group>"; };
A9280FDF3E0D43AC90382DBC /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Sauci/GoogleService-Info.plist"; sourceTree = "<group>"; };
@@ -64,6 +60,7 @@
DA536CAC88B349A59DFC1F1E /* Sauci-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Sauci-Bridging-Header.h"; path = "Sauci/Sauci-Bridging-Header.h"; sourceTree = "<group>"; };
DDDAD8C12C38D3BD0A76D345 /* Pods_Sauci.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; fileEncoding = 4; includeInIndex = undefined; path = Pods_Sauci.framework; sourceTree = BUILT_PRODUCTS_DIR; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Sauci/AppDelegate.swift; sourceTree = "<group>"; };
F744EAD4ABA54509966475D6 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Sauci/noop-file.swift"; sourceTree = "<group>"; };
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
XX470101BAEB8E0BBC8B0DXX /* WidgetKit.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
@@ -126,11 +123,9 @@
isa = PBXGroup;
children = (
BB2F792B24A3F905000567C9 /* Supporting */,
13B07FAF1A68108700A75B9A /* AppDelegate.h */,
13B07FB01A68108700A75B9A /* AppDelegate.mm */,
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
13B07FB51A68108700A75B9A /* Images.xcassets */,
13B07FB61A68108700A75B9A /* Info.plist */,
13B07FB71A68108700A75B9A /* main.m */,
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
A9280FDF3E0D43AC90382DBC /* GoogleService-Info.plist */,
F744EAD4ABA54509966475D6 /* noop-file.swift */,
@@ -421,6 +416,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon/PurchasesHybridCommon.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat/RevenueCat.bundle",
@@ -455,6 +451,7 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/PurchasesHybridCommon.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RevenueCat.bundle",
@@ -497,11 +494,16 @@
inputFileListPaths = (
);
inputPaths = (
"$(SRCROOT)/.xcode.env",
"$(SRCROOT)/.xcode.env.local",
"$(SRCROOT)/Sauci/Sauci.entitlements",
"$(SRCROOT)/Pods/Target Support Files/Pods-Sauci/expo-configure-project.sh",
);
name = "[Expo] Configure project";
outputFileListPaths = (
);
outputPaths = (
"$(SRCROOT)/Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
@@ -527,8 +529,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
13B07FC11A68108700A75B9A /* main.m in Sources */,
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */,
5254BDADE70741A0B2504FB1 /* noop-file.swift in Sources */,
);
@@ -569,7 +570,7 @@
INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.5;
MARKETING_VERSION = 1.0.6;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -598,7 +599,7 @@
INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.5;
MARKETING_VERSION = 1.0.6;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -671,6 +672,8 @@
"${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios",
"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers/platform/ios",
);
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
@@ -738,6 +741,8 @@
"${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios",
"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers/platform/ios",
);
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
@@ -767,7 +772,7 @@
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 41;
CURRENT_PROJECT_VERSION = 44;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = BXVPBATDZY;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -777,7 +782,7 @@
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.0.5;
MARKETING_VERSION = 1.0.6;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app.LiveDrawWidget;
@@ -803,7 +808,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 41;
CURRENT_PROJECT_VERSION = 44;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = BXVPBATDZY;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -813,7 +818,7 @@
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.0.5;
MARKETING_VERSION = 1.0.6;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
-7
View File
@@ -1,7 +0,0 @@
#import <RCTAppDelegate.h>
#import <UIKit/UIKit.h>
#import <Expo/Expo.h>
@interface AppDelegate : EXAppDelegateWrapper
@end
-66
View File
@@ -1,66 +0,0 @@
#import "AppDelegate.h"
#import <Firebase/Firebase.h>
#import <React/RCTBundleURLProvider.h>
#import <React/RCTLinkingManager.h>
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
// @generated begin @react-native-firebase/app-didFinishLaunchingWithOptions - expo prebuild (DO NOT MODIFY) sync-ecd111c37e49fdd1ed6354203cd6b1e2a38cccda
[FIRApp configure];
// @generated end @react-native-firebase/app-didFinishLaunchingWithOptions
self.moduleName = @"main";
// You can add your custom initial props in the dictionary below.
// They will be passed down to the ViewController used by React Native.
self.initialProps = @{};
return [super application:application didFinishLaunchingWithOptions:launchOptions];
}
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
return [self bundleURL];
}
- (NSURL *)bundleURL
{
#if DEBUG
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@".expo/.virtual-metro-entry"];
#else
return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
#endif
}
// Linking API
- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options {
return [super application:application openURL:url options:options] || [RCTLinkingManager application:application openURL:url options:options];
}
// Universal Links
- (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity restorationHandler:(nonnull void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler {
BOOL result = [RCTLinkingManager application:application continueUserActivity:userActivity restorationHandler:restorationHandler];
return [super application:application continueUserActivity:userActivity restorationHandler:restorationHandler] || result;
}
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
return [super application:application didRegisterForRemoteNotificationsWithDeviceToken:deviceToken];
}
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error
{
return [super application:application didFailToRegisterForRemoteNotificationsWithError:error];
}
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
{
return [super application:application didReceiveRemoteNotification:userInfo fetchCompletionHandler:completionHandler];
}
@end
+75
View File
@@ -0,0 +1,75 @@
import Expo
import FirebaseCore
import React
import ReactAppDependencyProvider
@UIApplicationMain
public class AppDelegate: ExpoAppDelegate {
var window: UIWindow?
var reactNativeDelegate: ExpoReactNativeFactoryDelegate?
var reactNativeFactory: RCTReactNativeFactory?
public override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
let delegate = ReactNativeDelegate()
let factory = ExpoReactNativeFactory(delegate: delegate)
delegate.dependencyProvider = RCTAppDependencyProvider()
reactNativeDelegate = delegate
reactNativeFactory = factory
bindReactNativeFactory(factory)
#if os(iOS) || os(tvOS)
window = UIWindow(frame: UIScreen.main.bounds)
FirebaseApp.configure()
factory.startReactNative(
withModuleName: "main",
in: window,
launchOptions: launchOptions)
#endif
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
public override func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
super.application(app, open: url, options: options)
|| RCTLinkingManager.application(app, open: url, options: options)
}
public override func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
let result = RCTLinkingManager.application(
application,
continue: userActivity,
restorationHandler: restorationHandler)
return super.application(
application,
continue: userActivity,
restorationHandler: restorationHandler) || result
}
}
class ReactNativeDelegate: ExpoReactNativeFactoryDelegate {
override func sourceURL(for bridge: RCTBridge) -> URL? {
bridge.bundleURL ?? bundleURL()
}
override func bundleURL() -> URL? {
#if DEBUG
RCTBundleURLProvider.sharedSettings()
.jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry")
#else
Bundle.main.url(forResource: "main", withExtension: "jsbundle")
#endif
}
}
+6 -2
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.5</string>
<string>1.0.6</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@@ -47,9 +47,11 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>43</string>
<string>44</string>
<key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key>
<false/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
@@ -75,6 +77,8 @@
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
</array>
<key>RCTNewArchEnabled</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
-10
View File
@@ -1,10 +0,0 @@
#import <UIKit/UIKit.h>
#import "AppDelegate.h"
int main(int argc, char * argv[]) {
@autoreleasepool {
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}
@@ -0,0 +1,65 @@
import { fireEvent, render } from '@/test/test-utils';
import { PendingQuestionsCard, formatWaitingSince } from '@/components/discovery/PendingQuestionsCard';
import type { PendingQuestion } from '@/store';
const pendingQuestion = (id: string, partnerAnsweredAt: string): PendingQuestion => ({
id: `response-${id}`,
partnerAnsweredAt,
question: {
id,
pack_id: 'pack-1',
text: `Question ${id}`,
intensity: 1,
created_at: '2026-08-01T00:00:00.000Z',
pack: { id: 'pack-1', name: 'Connection', icon: 'heart' },
},
});
describe('PendingQuestionsCard', () => {
it('stays out of Home when there is nothing to answer', () => {
const { queryByTestId } = render(
<PendingQuestionsCard questions={[]} partnerName="Sam" onPress={jest.fn()} />,
);
expect(queryByTestId('pending-questions-card')).toBeNull();
});
it('names the partner, shows the count, and preserves answer privacy', () => {
const questions = [
pendingQuestion('q1', '2026-08-29T09:00:00.000Z'),
pendingQuestion('q2', '2026-08-29T10:00:00.000Z'),
];
const { getByText, getByLabelText } = render(
<PendingQuestionsCard questions={questions} partnerName="Sam" onPress={jest.fn()} />,
);
expect(getByText('Sam has answered 2 questions')).toBeTruthy();
expect(getByText('Answer yours to see where you match. Their answers stay private until you respond.')).toBeTruthy();
expect(getByLabelText('2 questions waiting')).toBeTruthy();
});
it('starts the answer action from its accessible CTA', () => {
const onPress = jest.fn();
const { getByTestId } = render(
<PendingQuestionsCard
questions={[pendingQuestion('q1', '2026-08-29T09:00:00.000Z')]}
partnerName="Sam"
onPress={onPress}
/>,
);
fireEvent.press(getByTestId('pending-questions-answer'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
describe('formatWaitingSince', () => {
const now = new Date('2026-08-29T12:00:00.000Z');
it('formats useful compact waiting ages', () => {
expect(formatWaitingSince('2026-08-29T11:59:30.000Z', now)).toBe('just now');
expect(formatWaitingSince('2026-08-29T11:42:00.000Z', now)).toBe('18m ago');
expect(formatWaitingSince('2026-08-29T09:00:00.000Z', now)).toBe('3h ago');
expect(formatWaitingSince('2026-08-27T12:00:00.000Z', now)).toBe('2d ago');
});
});
@@ -0,0 +1,47 @@
import { router } from 'expo-router';
import { handleNotificationResponse } from '@/lib/notifications';
import { useMatchStore } from '@/store/matchStore';
jest.mock('expo-router', () => ({
router: { push: jest.fn() },
}));
jest.mock('expo-notifications', () => ({
setNotificationHandler: jest.fn(),
addNotificationResponseReceivedListener: jest.fn(),
getLastNotificationResponseAsync: jest.fn(),
getPermissionsAsync: jest.fn(),
requestPermissionsAsync: jest.fn(),
getExpoPushTokenAsync: jest.fn(),
}));
jest.mock('@/lib/crashlytics', () => ({
captureError: jest.fn(),
}));
const notificationResponse = (type: string) => ({
notification: {
request: {
content: { data: { type } },
},
},
}) as never;
describe('handleNotificationResponse', () => {
beforeEach(() => {
useMatchStore.setState({ currentView: 'active' });
});
it('opens the answer queue for a catch-up reminder', () => {
handleNotificationResponse(notificationResponse('catchup_reminder'));
expect(useMatchStore.getState().currentView).toBe('pending');
expect(router.push).toHaveBeenCalledWith('/(app)/swipe?mode=pending');
});
it('ignores unknown notification types', () => {
handleNotificationResponse(notificationResponse('unknown'));
expect(router.push).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,208 @@
import { useMemo } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, { FadeInDown } from 'react-native-reanimated';
import type { PendingQuestion } from '../../store';
import { colors, gradients, radius, shadows, spacing, typography } from '../../theme';
interface PendingQuestionsCardProps {
questions: PendingQuestion[];
partnerName?: string | null;
onPress: () => void;
delay?: number;
}
export function formatWaitingSince(dateString: string, now = new Date()): string {
const answeredAt = new Date(dateString);
const diffMs = Math.max(0, now.getTime() - answeredAt.getTime());
const minutes = Math.floor(diffMs / 60_000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return answeredAt.toLocaleDateString();
}
export function PendingQuestionsCard({
questions,
partnerName,
onPress,
delay = 0,
}: PendingQuestionsCardProps) {
const oldestQuestion = useMemo(
() => questions.reduce<PendingQuestion | null>((oldest, question) => {
if (!oldest) return question;
return new Date(question.partnerAnsweredAt) < new Date(oldest.partnerAnsweredAt)
? question
: oldest;
}, null),
[questions],
);
if (!oldestQuestion) return null;
const count = questions.length;
const subject = partnerName?.trim() || 'Your partner';
const questionLabel = count === 1 ? 'question' : 'questions';
return (
<Animated.View
entering={FadeInDown.delay(delay).duration(450).springify()}
style={styles.container}
testID="pending-questions-card"
>
<View style={[styles.card, shadows.md]}>
<View style={styles.accent} />
<View style={styles.labelRow}>
<View style={styles.labelGroup}>
<View style={styles.statusDot} />
<Text style={styles.label}>Your turn</Text>
</View>
<View style={styles.countBadge} accessibilityLabel={`${count} ${questionLabel} waiting`}>
<Text style={styles.countText}>{count}</Text>
</View>
</View>
<Text style={styles.title}>
{subject} has answered {count} {questionLabel}
</Text>
<Text style={styles.description}>
Answer yours to see where you match. Their answers stay private until you respond.
</Text>
<TouchableOpacity
onPress={onPress}
activeOpacity={0.85}
accessibilityRole="button"
accessibilityLabel={`Answer ${count} waiting ${questionLabel}`}
testID="pending-questions-answer"
>
<LinearGradient
colors={gradients.primary as [string, string, ...string[]]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.button}
>
<Text style={styles.buttonText}>Answer now</Text>
<Ionicons name="arrow-forward" size={17} color={colors.text} />
</LinearGradient>
</TouchableOpacity>
<View style={styles.waitingRow}>
<Ionicons name="time-outline" size={13} color={colors.textTertiary} />
<Text style={styles.waitingText}>
Oldest waiting · {formatWaitingSince(oldestQuestion.partnerAnsweredAt)}
</Text>
</View>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
paddingHorizontal: spacing.lg,
marginTop: spacing.sm,
marginBottom: spacing.sm,
},
card: {
position: 'relative',
overflow: 'hidden',
padding: spacing.md,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(232, 164, 174, 0.36)',
backgroundColor: colors.surface,
},
accent: {
position: 'absolute',
top: 0,
right: 0,
width: 96,
height: 96,
borderBottomLeftRadius: 96,
backgroundColor: colors.primaryLight,
},
labelRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
labelGroup: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
statusDot: {
width: 8,
height: 8,
borderRadius: radius.full,
backgroundColor: colors.primary,
shadowColor: colors.primary,
shadowOpacity: 0.55,
shadowRadius: 6,
shadowOffset: { width: 0, height: 0 },
},
label: {
...typography.caption1,
color: colors.premium.rose,
fontWeight: '700',
letterSpacing: 1.2,
textTransform: 'uppercase',
},
countBadge: {
minWidth: 32,
height: 26,
paddingHorizontal: spacing.sm,
borderRadius: radius.full,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.primary,
},
countText: {
...typography.caption1,
color: colors.text,
fontWeight: '800',
},
title: {
...typography.title3,
color: colors.text,
marginTop: spacing.md,
paddingRight: spacing.xl,
},
description: {
...typography.subhead,
color: colors.textSecondary,
marginTop: spacing.sm,
marginBottom: spacing.md,
},
button: {
minHeight: 46,
borderRadius: radius.md,
paddingHorizontal: spacing.md,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
},
buttonText: {
...typography.callout,
color: colors.text,
fontWeight: '700',
},
waitingRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginTop: spacing.sm,
},
waitingText: {
...typography.caption2,
color: colors.textTertiary,
},
});
@@ -4,3 +4,4 @@ export { CompactHeader } from './CompactHeader';
export { CircularProgress } from './CircularProgress';
export { LiveDrawTile } from './LiveDrawTile';
export { DaresTile } from './DaresTile';
export { PendingQuestionsCard } from './PendingQuestionsCard';
+4
View File
@@ -216,6 +216,10 @@ export const Events = {
questionSkipped: () => logEvent("question_skipped"),
matchCreated: (matchType: string) => logEvent("match_created", { match_type: matchType }),
allQuestionsExhausted: () => logEvent("all_questions_exhausted"),
pendingQuestionsPromptViewed: (count: number) =>
logEvent("pending_questions_prompt_viewed", { question_count: count }),
pendingQuestionsPromptTapped: (count: number) =>
logEvent("pending_questions_prompt_tapped", { question_count: count }),
// Chat events
matchViewed: () => logEvent("match_viewed"),
+8 -1
View File
@@ -23,7 +23,7 @@ Notifications.setNotificationHandler({
// Types
export interface NotificationData {
type: "match" | "message" | "match_digest" | "partner_activity" | "nudge" | "weekly_summary";
type: "match" | "message" | "match_digest" | "partner_activity" | "nudge" | "weekly_summary" | "catchup_reminder";
match_id?: string;
message_id?: string;
count?: number;
@@ -36,6 +36,7 @@ const notificationTypes = new Set<NotificationData["type"]>([
"partner_activity",
"nudge",
"weekly_summary",
"catchup_reminder",
]);
function isNotificationData(value: unknown): value is NotificationData {
@@ -261,6 +262,12 @@ export function handleNotificationResponse(
return;
}
if (data.type === "catchup_reminder") {
useMatchStore.getState().setCurrentView('pending');
router.push("/(app)/swipe?mode=pending");
return;
}
if (!data.match_id) {
return;
}
+2 -2
View File
@@ -8,8 +8,8 @@
<string>com.apple.widgetkit-extension</string>
</dict>
<key>CFBundleVersion</key>
<string>43</string>
<string>44</string>
<key>CFBundleShortVersionString</key>
<string>1.0.5</string>
<string>1.0.6</string>
</dict>
</plist>
+7 -4
View File
@@ -1,9 +1,12 @@
# Agent routing and effort
The primary agent owns scope, acceptance criteria, task decomposition, and the
final answer. Use one implementation agent when a bounded handoff will keep noisy
execution out of the primary context. Do not pay the coordination cost for a
trivial one-file edit. Add other agents only for separable work or a risk trigger.
The primary agent owns scope, acceptance criteria, task decomposition, review,
and the final answer. Every substantive repository task must delegate at least
one bounded unit to the best-fit project agent. Use `repo_scout` for read-only
discovery or analysis and `implementation` for product or harness changes. Add
other agents only for separable work or the risk triggers below. Skip delegation
only for trivial conversation, a single obvious lookup, or a mechanical one-line
edit whose coordination cost exceeds the work.
## Implementation and integration
+10
View File
@@ -1,5 +1,15 @@
# Sauci Mobile Release Notes
## v1.0.6 — 30 August 2026
_iOS build 44 · Android build 48_
A little more clarity, momentum, and playfulness for your shared check-ins.
- **Your turn at a glance** — A private Home prompt now shows when questions from your partner are waiting, without revealing their answers.
- **New ways to play** — Browse, send, and respond to Dares together from a dedicated shared space.
- **Streaks that fit your day** — Shared streaks now follow your local day, with clearer progress and reminder controls.
- **Smoother shared moments** — Leaner syncing keeps questions, matches, chat, and Live Draw feeling current with less unnecessary refreshing.
## v1.0.1 (Build 30) - January 2025
### New Features
+56 -3
View File
@@ -33,6 +33,46 @@ Output files are placed in the current directory by default.
These scripts create signed artifacts only. They do not upload or submit them.
Store mutation always requires a separate, explicitly authorized command.
## Submit a local artifact with EAS
The production submission credentials are managed by EAS. Never add App Store
Connect `.p8` files, Google service-account JSON files, or local credential paths
to the repository or `eas.json`.
Submit only an independently verified artifact by its exact absolute path:
```bash
cd apps/mobile
# Uploads an Android internal-track draft; it does not roll out the release.
eas submit --platform android --profile production --path /absolute/path/to/sauci.aab --wait
# Uploads to App Store Connect/TestFlight; it does not submit for App Review.
eas submit --platform ios --profile production --path /absolute/path/to/sauci.ipa --wait
```
Agents must not upload immediately after building, whether they use EAS,
Fastlane, Xcode Organizer, or a store console. Once the artifact is verified, the
agent must show the user the platform, exact path, bundle/package identifier,
version, build number, signing identity, SHA-256 digest, and destination, then ask
for a fresh confirmation to upload it. This confirmation authorizes one upload
of that exact artifact to that exact destination, even if the earlier request
already said to build, release, upload, or submit.
Before an iOS confirmation, inspect App Store Connect for automatic TestFlight
distribution. If upload will add the build to existing groups or testers, name
them in the confirmation and include that effect in the authorization. If the
effect cannot be established, stop. Changing automatic-distribution settings is
a separate action and requires its own authorization.
Do not use `--latest` for a local release. Before upload, verify the artifact's
package or bundle identifier, public version, build number, signature, and
SHA-256 digest. After upload, read back the same identity and version from Google
Play Console or App Store Connect. Public rollout, App Review submission, and
additional TestFlight group distribution remain separate, explicitly authorized
actions. If an upload command returns an ambiguous result, inspect EAS and the
store first; do not retry unless the user gives fresh authorization.
### Source integrity
Sauci commits its native `ios/` and `android/` projects. Start a release from a
@@ -196,12 +236,19 @@ android/app/build/outputs/bundle/release/app-release.aab
### Step 5: Upload to Play Store
Apply the upload authorization gate above before opening or changing the release.
Uploading the AAB and starting any rollout are separate actions; ask again before
the rollout.
1. Go to [Google Play Console](https://play.google.com/console)
2. Select **Sauci** app
3. Go to **Production** (or testing track)
3. Go to **Internal testing**, unless the user authorized a different exact track
4. **Create new release**
5. Upload the `.aab` file
6. Add release notes and submit
6. Add release notes, save the release as a draft, and read it back
Do not send the draft for review or start a rollout without the separate fresh
authorization described above.
### Verifying Keystore Fingerprint
@@ -211,7 +258,7 @@ To verify you're using the correct keystore:
keytool -list -v -keystore android/sauci-release.keystore -storepass <password>
```
Expected SHA1: `C3:70:C5:8C:85:E5:B5:92:97:82:A9:B0:47:98:40:99:AC:C8:47:43`
Expected EAS production upload-key SHA1: `F0:60:26:C7:54:B0:46:F2:80:FE:AC:43:8A:69:10:BD:9A:62:B4:59`
---
@@ -239,6 +286,9 @@ cd ..
### Step 4: Upload to App Store
Apply the upload authorization gate above before using Xcode Organizer. Include
any verified automatic TestFlight distribution in the confirmation.
1. In Xcode Organizer (Window → Organizer), select the archive
2. Click **Distribute App**
3. Select **App Store Connect** → **Upload**
@@ -246,6 +296,9 @@ cd ..
### Step 5: Submit in App Store Connect
Submitting for App Review is not authorized by the upload confirmation. Ask the
user for fresh authorization immediately before this step.
1. Go to [App Store Connect](https://appstoreconnect.apple.com)
2. Select **Sauci**
3. Create a new version or select the uploaded build