diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..52018ad --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: CI + +on: + push: + branches: [ main, develop, '**' ] + pull_request: + branches: [ main, develop ] + workflow_dispatch: + inputs: + beta: + description: 'Run beta lane' + required: false + default: 'false' + +jobs: + functions-tests: + runs-on: ubuntu-latest + defaults: + run: + working-directory: functions + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - run: npm run build + - run: npm test + + ios-build: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Select Xcode + run: sudo xcode-select -s "/Applications/Xcode.app/Contents/Developer" + - name: Install xcpretty + run: gem install xcpretty + - name: Build App + run: xcodebuild -project ios/ResilientMe.xcodeproj -scheme ResilientMe -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' build | xcpretty + shell: bash + env: + NSUnbufferedIO: YES + + ios-fastlane-test: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + - name: Install Bundler and Fastlane + run: | + gem install bundler fastlane + - name: Run Fastlane test + working-directory: ios + run: fastlane test + + ios-fastlane-beta: + if: ${{ github.event.inputs.beta == 'true' }} + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + - name: Install Bundler and Fastlane + run: | + gem install bundler fastlane + - name: Run Fastlane beta + working-directory: ios + env: + APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }} + run: fastlane beta \ No newline at end of file diff --git a/.github/workflows/firebase-deploy.yml b/.github/workflows/firebase-deploy.yml new file mode 100644 index 0000000..da4049b --- /dev/null +++ b/.github/workflows/firebase-deploy.yml @@ -0,0 +1,35 @@ +name: Firebase Deploy + +on: + workflow_dispatch: + inputs: + target: + description: 'Firebase target: default|staging|production' + required: false + default: 'default' + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Install Functions deps + working-directory: functions + run: npm ci + - name: Build Functions + working-directory: functions + run: npm run build + - name: Write GCP service account key + run: | + echo "${{ secrets.GOOGLE_APPLICATION_CREDENTIALS_JSON }}" > $RUNNER_TEMP/gcp-key.json + echo "PROJECT_ID=$(if [ \"${{ github.event.inputs.target }}\" = \"production\" ]; then echo resilientme-prod; else echo resilientme-staging; fi)" >> $GITHUB_ENV + - name: Firebase deploy (rules, indexes, functions) + env: + GOOGLE_APPLICATION_CREDENTIALS: ${{ runner.temp }}/gcp-key.json + run: | + npm i -g firebase-tools + firebase deploy --project $PROJECT_ID --only firestore:rules,firestore:indexes,functions \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..79a9d16 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# ResilientMe (iOS + Firebase) + +Turn rejection into resilience. ResilientMe helps Gen Z log rejections in seconds, surface patterns, and bounce back with actionable recovery tools and community support. + +## Highlights +- 30-sec Quick Log with types, impact slider, optional note/screenshot, and haptics +- Smart Dashboard with resilience score, weekly stats, trends, and pattern alerts +- Recovery Hub with contextual plans, quick actions, and response templates +- Daily Challenges with server-side generation and streaks +- Anonymous Community feed with reactions, moderation/reporting, and callables +- Offline-first logging with Core Data and background sync queue +- Push notifications (daily check-in, recovery follow-ups) and deep-links to tabs +- Privacy-first: anonymous by default, biometric lock, export/delete data +- CI/CD with GitHub Actions + Fastlane; Firestore/Storage rules + tests + +## Repo structure +``` +functions/ # Firebase Cloud Functions (TypeScript) +ios/ResilientMe/ # iOS SwiftUI app +ios/fastlane/ # Fastlane lanes for test/beta +.github/workflows/ # CI (functions tests, iOS build, fastlane) +firestore.rules # Firestore security rules +firestore.indexes.json # Firestore indexes +storage.rules # Storage rules +ios/ResilientMe/PrivacyInfo.xcprivacy # Privacy Manifest +ios/AppStorePrivacy.json # App Store privacy summary (reference) +``` + +## Architecture (quick) +- Client (SwiftUI): Views β†’ Managers (state/logic) β†’ Services (integration) β†’ Models +- Local: Core Data (offline queue), plus transient in-memory state +- Cloud: Firestore (users/*, community, aggregates), Functions (callables, triggers, schedulers), Storage (images), FCM; GA4 analytics; Remote Config flags + +Details: see `docs/ARCHITECTURE.md` and `docs/API.md`. + +## Getting started (local) +- iOS: open `ios/ResilientMe.xcodeproj` (SwiftUI, iOS 15+). Build and run on simulator. +- Firebase emulators (optional): `cd functions && npm i && npm run serve` +- Functions build/test: `cd functions && npm i && npm run build && npm test` + +Note: You can run CI-only paths without local builds. See CI and release below. + +## Security +- Firestore rules enforce least privilege. `users/{uid}/**` only by owner. `community` is read-only for clients. Server-only collections (`userReactions`, `communityReports`, `rateLimits`) deny all client access. +- Storage rules: `rejection_images/{uid}/**` only by owner. +- Functions: input sanitization (content), rate limiting (per user/window), moderation/report flow. + +More: `docs/SECURITY_PRIVACY.md`. + +## Analytics & Remote Config +- GA4 events wired (screen views, core actions). See `docs/ANALYTICS.md`. +- Remote Config: `communityEnabled`, `challengeDifficulty`. See `docs/CONFIG.md`. + +## CI/CD +- GitHub Actions: functions tests, iOS simulator build, fastlane test; optional manual TestFlight (`beta=true`). +- Fastlane lanes (in `ios/fastlane`): `test` and `beta`. + +See `docs/RELEASE.md` for secrets and steps. + +## Development standards +- Code style: SwiftUI + managers/services, meaningful naming, accessible UI (44pt targets, labels), skeletons and empty states. +- Design tokens: Dark theme, brand colors/typography. +- Docs: see `/docs/*` for deep dives. + +## Documentation map +- Architecture: `docs/ARCHITECTURE.md` +- Cloud APIs: `docs/API.md` +- Security & Privacy: `docs/SECURITY_PRIVACY.md` +- Analytics taxonomy: `docs/ANALYTICS.md` +- Config & flags: `docs/CONFIG.md` +- Testing: `docs/TESTING.md` +- Release & App Store: `docs/RELEASE.md` +- UX & design system: `docs/UX.md` +- Troubleshooting: `docs/TROUBLESHOOTING.md` + +## License +Proprietary. All rights reserved. \ No newline at end of file diff --git a/docs/ANALYTICS.md b/docs/ANALYTICS.md new file mode 100644 index 0000000..f697f1f --- /dev/null +++ b/docs/ANALYTICS.md @@ -0,0 +1,13 @@ +# Analytics (GA4) + +## Events +- `screen_view` (auto) with screen name +- `rejection_log` { type } +- `challenge_complete` +- `challenge_skip` +- `community_post` +- `reaction_add` { reaction } + +## Notes +- Debug with GA4 DebugView. +- Consider adding user properties (e.g., anonymous_mode) later. \ No newline at end of file diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..1f1d7e9 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,21 @@ +# Cloud API + +## Callables +- `createCommunityPost({ type: 'dating'|'job'|'social'|'other', content: string })` β†’ `{ id }` + - Sanitizes input, rate-limits, sets `status: 'visible'` +- `reactToPost({ postId, reaction: 'πŸ’ͺ'|'πŸ˜”'|'πŸŽ‰'|'πŸ«‚' })` β†’ `{ ok: true }` + - Dedupe per user via `userReactions` and atomic increment +- `reportPost({ postId })` β†’ `{ ok: true }` + - Increments `reports`, hides at threshold +- `requestDataExport()` β†’ `{ url }` + - Bundles user data to Storage, returns 1h signed URL; rate-limited +- `requestAccountDeletion()` β†’ `{ ok: true }` + - Deletes user subcollections, images, user doc, Auth user +- `backfillCommunityStatus()` [admin] + - Sets `status: 'visible'` on recent posts lacking it + +## Triggers & schedules +- Firestore: `onRejectionCreate` aggregates, server patterns, optional FCM +- Firestore: `onRejectionDelete` removes Storage image +- Pub/Sub: `generateDailyChallenges` daily at 05:00 UTC +- Pub/Sub: `cleanupStaleDocs` every 24h \ No newline at end of file diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..04038f0 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,36 @@ +# Architecture + +## Overview +- SwiftUI client with Managers (state/logic), Services (integration), and Models +- Firebase backend: Firestore, Functions, Storage, Auth, Remote Config, FCM, GA4 +- Offline-first with Core Data and a background sync queue + +## Client layers +- Views (SwiftUI): `Views/*` +- Managers: `Managers/*` (e.g., RejectionManager, CommunityManager, ChallengeManager) +- Services: `Services/*` (FirestoreSyncService, FirebaseConfigurator, ImageUploadService) +- Models: `Models/*` +- Design: `Extensions/DesignSystem.swift`, `Assets` + +## Data model (core) +- RejectionEntry: id, type, emotionalImpact, note, timestamp, imageUrl? +- Aggregates: users/{uid}/aggregates/{dayKey}, users/{uid}/aggregates/patterns +- Community: `community/{postId}` with `type`, `content`, `createdAt`, `reactions`, `status` + +## Cloud functions +- Rejections: onCreate aggregates + server-side pattern detection + push +- Community: callable `createCommunityPost`, `reactToPost` (dedupe), `reportPost` (hide when reports >= 3) +- Challenges: scheduled `generateDailyChallenges` +- Data lifecycle: callables `requestDataExport` (signed URL), `requestAccountDeletion` +- Maintenance: `cleanupStaleDocs` (rateLimits/userReactions), `backfillCommunityStatus` + +## Security +- Firestore: user-owned subtree; community read-only; server-only collections deny client access +- Storage: images path per user; rules allow only owner + +## Notifications +- Local: daily check-in, recovery follow-ups; categories and deep-link routing +- Remote: FCM token saved per user; server sends on rejections (if token) + +## Analytics & Config +- Screen_view + action events; Remote Config flags (`communityEnabled`, `challengeDifficulty`) \ No newline at end of file diff --git a/docs/CONFIG.md b/docs/CONFIG.md new file mode 100644 index 0000000..0906458 --- /dev/null +++ b/docs/CONFIG.md @@ -0,0 +1,8 @@ +# Remote Config + +## Flags +- `communityEnabled` (bool): shows/hides Community tab +- `challengeDifficulty` (string): reserved for tuning challenge generator + +## Client +- `RemoteConfigManager` loads flags on app start and gates `ContentView` tabs. \ No newline at end of file diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..4adfb09 --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,19 @@ +# Release & CI/CD + +## GitHub Actions +- Functions tests, iOS build, Fastlane test +- Manual beta trigger: Actions β†’ Run workflow β†’ set `beta=true` + +## Secrets +- `APP_STORE_CONNECT_API_KEY_ID` +- `APP_STORE_CONNECT_ISSUER_ID` +- `APP_STORE_CONNECT_API_KEY` (base64 .p8) +- (Optional) `FIREBASE_TOKEN` for deploys + +## Firebase deploys +- Rules/indexes: `firebase deploy --only firestore:rules,firestore:indexes` +- Functions: `cd functions && npm i && npm run build && npm run deploy` + +## TestFlight +- Trigger Fastlane beta after secrets added. Upload occurs via App Store Connect API key. +- Add testers and notes in App Store Connect. \ No newline at end of file diff --git a/docs/SECURITY_PRIVACY.md b/docs/SECURITY_PRIVACY.md new file mode 100644 index 0000000..6e404d5 --- /dev/null +++ b/docs/SECURITY_PRIVACY.md @@ -0,0 +1,17 @@ +# Security & Privacy + +## Firestore rules +- `users/{userId}/{document=**}`: allow if `request.auth.uid == userId` +- `community/{postId}`: allow read; deny all client writes (Functions only) +- `userReactions`, `communityReports`, `rateLimits`: deny client read/write + +## Storage rules +- `rejection_images/{userId}/{allPaths=**}`: allow if `request.auth.uid == userId` + +## Functions +- Sanitizes text input for community posts +- Rate-limits callables (per user/window) via `rateLimits` +- Deletes Storage images on entry/account deletion + +## Privacy Manifest +- `ios/ResilientMe/PrivacyInfo.xcprivacy`: no tracking; data types include User ID, Device ID, Product Interaction, Email Address; purposes are App Functionality, Analytics, Account Management \ No newline at end of file diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..785afb3 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,12 @@ +# Testing + +## Functions +- Install deps: `cd functions && npm i` +- Build: `npm run build` +- Run tests: `npm test` + - Rules tests cover users/community/server-only collections + - Storage tests cover user image paths + +## iOS UI Tests +- `ResilientMeUITests` includes Quick Log flow and Community tab presence +- CI runs fastlane test on simulator (see GitHub Actions) \ No newline at end of file diff --git a/docs/UX.md b/docs/UX.md new file mode 100644 index 0000000..65fbbaf --- /dev/null +++ b/docs/UX.md @@ -0,0 +1,19 @@ +# UX & Design System + +## Tokens +- Dark theme; primary blue, energy orange; Inter typography as in design tokens +- Card style: 16pt radius, systemGray6 backgrounds, consistent paddings + +## Patterns +- 30-sec Quick Log: minimal required fields, big touch targets, haptics + success hint +- Skeletons on data fetch; empty states with actionable CTAs +- Optimistic UI on reactions; lightweight error banners on network failures +- Notification toggles in Settings (daily check-in, follow-ups) +- Onboarding: privacy-first (anonymous by default), notification CTA + +## Accessibility +- 44pt minimum targets; VoiceOver labels/values on list items; avoid reliance on color only; Dynamic Type friendly layouts + +## Microcopy +- Empathetic, Gen Z voice; short, encouraging phrases (e.g., β€œLogged. That’s strength.”) +- Avoid clinical/corporate tone; be direct and supportive \ No newline at end of file diff --git a/firestore.indexes.json b/firestore.indexes.json index 5eb3c70..4aeeb0d 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -4,6 +4,7 @@ "collectionGroup": "community", "queryScope": "COLLECTION", "fields": [ + { "fieldPath": "status", "order": "ASCENDING" }, { "fieldPath": "createdAt", "order": "DESCENDING" } ] }, @@ -11,7 +12,7 @@ "collectionGroup": "rejections", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "createdAt", "order": "DESCENDING" } + { "fieldPath": "timestamp", "order": "DESCENDING" } ] } ], diff --git a/firestore.rules b/firestore.rules index 536b45d..78889ff 100644 --- a/firestore.rules +++ b/firestore.rules @@ -1,13 +1,27 @@ rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { + // User-owned data: user can read/write only their subtree match /users/{userId}/{document=**} { allow read, write: if request.auth != null && request.auth.uid == userId; } + + // Community feed is write-only via server (Cloud Functions with Admin SDK bypass rules) match /community/{postId} { allow read: if true; - allow create: if request.auth != null && request.resource.data.size() < 2048; - allow update, delete: if request.auth != null && request.auth.uid == resource.data.authorUid; + // All client writes are denied; creation and updates happen via Cloud Functions + allow create, update, delete: if false; + } + + // Server-only collections (written by Functions) + match /userReactions/{docId} { + allow read, write: if false; + } + match /communityReports/{docId} { + allow read, write: if false; + } + match /rateLimits/{docId} { + allow read, write: if false; } } } diff --git a/functions/package.json b/functions/package.json index ca0d9d6..db4dc5f 100644 --- a/functions/package.json +++ b/functions/package.json @@ -7,7 +7,8 @@ "build": "tsc -p .", "serve": "npm run build && firebase emulators:start --only functions,firestore,auth,storage", "deploy": "npm run build && firebase deploy --only functions", - "lint": "eslint ." + "lint": "eslint .", + "test": "mocha -r ts-node/register tests/**/*.test.ts --timeout 20000" }, "dependencies": { "firebase-admin": "^12.5.0", @@ -15,6 +16,10 @@ }, "devDependencies": { "typescript": "^5.4.0", - "eslint": "^9.0.0" + "eslint": "^9.0.0", + "mocha": "^10.4.0", + "chai": "^4.5.0", + "ts-node": "^10.9.2", + "@firebase/rules-unit-testing": "^3.0.5" } } diff --git a/functions/src/index.ts b/functions/src/index.ts index eec1456..31fd668 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -3,28 +3,410 @@ import * as admin from 'firebase-admin'; admin.initializeApp(); +const db = admin.firestore(); +const auth = admin.auth(); +const storage = admin.storage(); + +// Utilities +const sanitizeText = (input: string): string => { + return input.trim().replace(/[<>]/g, '').slice(0, 2000); +}; + +const ensureAuthed = (context: functions.https.CallableContext): string => { + if (!context.auth) { + throw new functions.https.HttpsError('unauthenticated', 'Authentication required'); + } + return context.auth.uid; +}; + +const ensureAdmin = (context: functions.https.CallableContext) => { + if (!context.auth || !(context.auth.token as any)?.admin) { + throw new functions.https.HttpsError('permission-denied', 'Admin only'); + } +}; + +const rateLimitCheck = async (uid: string, key: string, limit: number, windowSeconds: number) => { + const bucketRef = db.collection('rateLimits').doc(`${uid}:${key}`); + const now = admin.firestore.Timestamp.now(); + await db.runTransaction(async (tx) => { + const snap = await tx.get(bucketRef); + const data = snap.exists ? snap.data() as any : { count: 0, windowStart: now }; + const windowStart: admin.firestore.Timestamp = data.windowStart || now; + const count: number = data.count || 0; + const elapsed = now.seconds - windowStart.seconds; + if (elapsed > windowSeconds) { + tx.set(bucketRef, { count: 1, windowStart: now }, { merge: true }); + } else { + if (count + 1 > limit) { + throw new functions.https.HttpsError('resource-exhausted', 'Rate limit exceeded'); + } + tx.set(bucketRef, { count: count + 1, windowStart }, { merge: true }); + } + }); +}; + +// Scheduled cleanup for stale rateLimits and userReactions older than 7 days +export const cleanupStaleDocs = functions.pubsub.schedule('every 24 hours').timeZone('Etc/UTC').onRun(async () => { + const sevenDaysAgo = admin.firestore.Timestamp.fromMillis(Date.now() - 7 * 24 * 60 * 60 * 1000); + const batch = db.batch(); + let count = 0; + // rateLimits: use windowStart timestamp + const rlSnap = await db.collection('rateLimits').get(); + rlSnap.forEach((d) => { + const data = d.data() as any; + const ws: admin.firestore.Timestamp | undefined = data.windowStart; + if (ws && ws.seconds < sevenDaysAgo.seconds) { + batch.delete(d.ref); count++; + } + }); + // userReactions: use createdAt + const urSnap = await db.collection('userReactions').get(); + urSnap.forEach((d) => { + const data = d.data() as any; + const ca: admin.firestore.Timestamp | undefined = data.createdAt; + if (ca && ca.seconds < sevenDaysAgo.seconds) { + batch.delete(d.ref); count++; + } + }); + if (count > 0) { await batch.commit(); } + return null; +}); + +// Backfill: set status: 'visible' where missing on recent community posts (admin only) +export const backfillCommunityStatus = functions.https.onCall(async (_data, context) => { + ensureAdmin(context); + const snap = await db.collection('community').orderBy('createdAt', 'desc').limit(1000).get(); + const batch = db.batch(); + let count = 0; + for (const doc of snap.docs) { + const data = doc.data() as any; + if (!Object.prototype.hasOwnProperty.call(data, 'status')) { + batch.set(doc.ref, { status: 'visible' }, { merge: true }); + count++; + } + } + if (count > 0) await batch.commit(); + return { updated: count }; +}); + +// On rejection create: update aggregates, compute patterns, and schedule follow-up notification export const onRejectionCreate = functions.firestore .document('users/{uid}/rejections/{id}') .onCreate(async (snap, context) => { - const data = snap.data(); + const data = snap.data() as any; const uid = context.params.uid as string; - const createdAt = data?.createdAt || admin.firestore.FieldValue.serverTimestamp(); // Update aggregate doc (daily) const day = new Date(); - day.setHours(0,0,0,0); + day.setHours(0, 0, 0, 0); const dayKey = day.toISOString().split('T')[0]; - const aggRef = admin.firestore().doc(`users/${uid}/aggregates/${dayKey}`); - await admin.firestore().runTransaction(async tx => { + const aggRef = db.doc(`users/${uid}/aggregates/${dayKey}`); + await db.runTransaction(async (tx) => { const doc = await tx.get(aggRef); - const current = doc.exists ? doc.data() : { totalLogs: 0, sumImpact: 0 }; - tx.set(aggRef, { - totalLogs: (current.totalLogs || 0) + 1, - sumImpact: (current.sumImpact || 0) + (data?.emotionalImpact || 0), - updatedAt: admin.firestore.FieldValue.serverTimestamp() - }, { merge: true }); + const current = doc.exists ? doc.data() as any : { totalLogs: 0, sumImpact: 0 }; + tx.set( + aggRef, + { + totalLogs: (current.totalLogs || 0) + 1, + sumImpact: (current.sumImpact || 0) + (data?.emotionalImpact || 0), + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + }, + { merge: true } + ); }); - // TODO: schedule follow-up notification via FCM + // Simple server-side pattern detection snapshot + try { + const recentSnap = await db + .collection(`users/${uid}/rejections`) + .orderBy('timestamp', 'desc') + .limit(200) + .get(); + const items = recentSnap.docs.map((d) => d.data()); + const patterns: Array<{ title: string; description: string; insight: string; actionable: string } | null> = []; + + // Ghosting keyword in notes + const dating = items.filter((i: any) => i.type === 'dating'); + const ghostCount = dating.filter((i: any) => (i.note || '').toLowerCase().includes('ghost')).length; + if (ghostCount >= 3 && ghostCount > Math.max(1, Math.floor(dating.length / 2))) { + patterns.push({ + title: 'Ghosting Pattern Detected', + description: `You\'ve been ghosted ${ghostCount} times recently`, + insight: "This is about their communication style, not your worth", + actionable: 'Try apps that require more investment upfront', + }); + } + + // Day-of-week spikes + const counts: Record = {}; + items.forEach((i: any) => { + const ts = i.timestamp?.toDate?.() || new Date(i.timestamp); + const day = (ts instanceof Date) ? ts.getDay() : 0; // 0..6 + counts[day] = (counts[day] || 0) + 1; + }); + const maxDay = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]; + if (maxDay && maxDay[1] >= Math.max(3, Math.floor(items.length / 3))) { + const weekday = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][parseInt(maxDay[0])]; + patterns.push({ + title: 'Timing Pattern', + description: `Most rejections occur on ${weekday}`, + insight: 'Consider adjusting outreach timing', + actionable: 'Avoid sending important messages on heavy days', + }); + } + + // Recovery improvement (approx: average impact falling) + const sorted = items + .filter((i: any) => typeof i.emotionalImpact === 'number') + .sort((a: any, b: any) => { + const ta = a.timestamp?.toDate?.() || new Date(a.timestamp); + const tb = b.timestamp?.toDate?.() || new Date(b.timestamp); + return ta.getTime() - tb.getTime(); + }); + if (sorted.length >= 4) { + const impacts = sorted.map((i: any) => i.emotionalImpact); + const half = Math.floor(impacts.length / 2); + const firstAvg = impacts.slice(0, half).reduce((s: number, v: number) => s + v, 0) / Math.max(1, half); + const secondAvg = impacts.slice(half).reduce((s: number, v: number) => s + v, 0) / Math.max(1, impacts.length - half); + if (secondAvg < firstAvg - 1.0) { + patterns.push({ + title: 'Recovery Improving', + description: 'Average impact decreased over time', + insight: "You're building resilience", + actionable: 'Keep consistent with small daily actions', + }); + } + } + + const patternsRef = db.doc(`users/${uid}/aggregates/patterns`); + await patternsRef.set({ + patterns: patterns.filter(Boolean), + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + }, { merge: true }); + } catch (e) { + console.error('Pattern analysis error', e); + } + + // Schedule follow-up via FCM topic or stored token if available + try { + const userDoc = await db.doc(`users/${uid}`).get(); + const token = userDoc.get('fcmToken'); + if (token) { + await admin.messaging().send({ + token, + notification: { + title: 'How are you doing?', + body: "Yesterday was tough. You're stronger than you know.", + }, + data: { + deep_link: 'resilientme://recovery', + }, + }); + } + } catch (e) { + console.error('FCM send error', e); + } + return true; }); + +// Callable: Create community post with sanitization and rate limiting +export const createCommunityPost = functions.https.onCall(async (data, context) => { + const uid = ensureAuthed(context); + await rateLimitCheck(uid, 'createPost', 5, 60 * 10); // 5 posts per 10 minutes + + const type = String(data?.type || '').toLowerCase(); + const content = sanitizeText(String(data?.content || '')); + if (!content || content.length < 3) { + throw new functions.https.HttpsError('invalid-argument', 'Content too short'); + } + const allowedTypes = new Set(['dating', 'job', 'social', 'other']); + if (!allowedTypes.has(type)) { + throw new functions.https.HttpsError('invalid-argument', 'Invalid type'); + } + + const doc = await db.collection('community').add({ + type, + content, + createdAt: admin.firestore.FieldValue.serverTimestamp(), + reactions: {}, + authorUid: uid, + status: 'visible', + }); + return { id: doc.id }; +}); + +// Callable: React to post with dedupe and basic per-user marker + rate limit +export const reactToPost = functions.https.onCall(async (data, context) => { + const uid = ensureAuthed(context); + await rateLimitCheck(uid, 'react', 20, 60); // 20 reactions per minute + + const postId = String(data?.postId || ''); + const reaction = String(data?.reaction || ''); + const allowed = new Set(['πŸ’ͺ', 'πŸ˜”', 'πŸŽ‰', 'πŸ«‚']); + if (!postId || !allowed.has(reaction)) { + throw new functions.https.HttpsError('invalid-argument', 'Invalid post/reaction'); + } + + const postRef = db.collection('community').doc(postId); + const markerRef = db.collection('userReactions').doc(`${uid}:${postId}`); + + await db.runTransaction(async (tx) => { + const marker = await tx.get(markerRef); + if (marker.exists) { + // Already reacted; no-op to dedupe + return; + } + tx.set(markerRef, { uid, postId, reaction, createdAt: admin.firestore.FieldValue.serverTimestamp() }, { merge: true }); + tx.set( + postRef, + { [`reactions.${reaction}`]: admin.firestore.FieldValue.increment(1) }, + { merge: true } + ); + }); + + return { ok: true }; +}); + +// Scheduled daily challenge generator +export const generateDailyChallenges = functions.pubsub.schedule('every day 05:00').timeZone('Etc/UTC').onRun(async () => { + // Iterate all users; for demo, scan user documents that have a profile + const usersSnap = await db.collection('users').get(); + const batch = db.batch(); + const today = new Date(); + const key = today.toISOString().split('T')[0]; + for (const user of usersSnap.docs) { + const uid = user.id; + const level = 'beginner'; // placeholder; could be derived from aggregates + const challenge = { + title: 'Self-Care Check', + description: 'Do one thing today that makes you feel good', + type: 'other', + difficulty: level, + points: 10, + timeEstimate: '15 minutes', + createdAt: admin.firestore.FieldValue.serverTimestamp(), + }; + const ref = db.doc(`users/${uid}/challenges/${key}`); + batch.set(ref, challenge, { merge: true }); + } + await batch.commit(); + return null; +}); + +// Callable: Request data export (dummy bundles user-owned subcollections) +export const requestDataExport = functions.https.onCall(async (_data, context) => { + const uid = ensureAuthed(context); + const userRef = db.collection('users').doc(uid); + const [rejectionsSnap, challengesSnap] = await Promise.all([ + userRef.collection('rejections').get(), + userRef.collection('challenges').get(), + ]); + const payload = { + rejections: rejectionsSnap.docs.map((d) => ({ id: d.id, ...d.data() })), + challenges: challengesSnap.docs.map((d) => ({ id: d.id, ...d.data() })), + generatedAt: new Date().toISOString(), + }; + // For MVP return directly; in production, store file and email link + return payload; +}); + +// Modify requestDataExport to write to Storage and return a signed URL +export const requestDataExport = functions.https.onCall(async (_data, context) => { + const uid = ensureAuthed(context); + await rateLimitCheck(uid, 'export', 3, 60 * 10); // 3 exports per 10 min + const userRef = db.collection('users').doc(uid); + const [rejectionsSnap, challengesSnap] = await Promise.all([ + userRef.collection('rejections').get(), + userRef.collection('challenges').get(), + ]); + const payload = { + rejections: rejectionsSnap.docs.map((d) => ({ id: d.id, ...d.data() })), + challenges: challengesSnap.docs.map((d) => ({ id: d.id, ...d.data() })), + generatedAt: new Date().toISOString(), + }; + const json = JSON.stringify(payload, null, 2); + const path = `exports/${uid}/${Date.now()}.json`; + await storage.bucket().file(path).save(Buffer.from(json), { contentType: 'application/json' }); + const [url] = await storage.bucket().file(path).getSignedUrl({ action: 'read', expires: Date.now() + 60 * 60 * 1000 }); + return { url }; +}); + +// Trigger: When a rejection is deleted, remove attached image from Storage if present +export const onRejectionDelete = functions.firestore + .document('users/{uid}/rejections/{id}') + .onDelete(async (snap, context) => { + const uid = context.params.uid as string; + const id = context.params.id as string; + const path = `rejection_images/${uid}/${id}.jpg`; + try { + await storage.bucket().file(path).delete({ ignoreNotFound: true }); + } catch (e) { + console.error('Storage delete error', e); + } + }); + +// Callable: Request account deletion (scrub PII, delete subcollections) +export const requestAccountDeletion = functions.https.onCall(async (_data, context) => { + const uid = ensureAuthed(context); + + // Delete user subcollections + const userRef = db.collection('users').doc(uid); + const rejections = await userRef.collection('rejections').listDocuments(); + const challenges = await userRef.collection('challenges').listDocuments(); + + // Delete associated Storage images for rejections + for (const doc of rejections) { + const path = `rejection_images/${uid}/${doc.id}.jpg`; + try { await storage.bucket().file(path).delete({ ignoreNotFound: true }); } catch {} + } + + const batches: FirebaseFirestore.WriteBatch[] = []; + let batch = db.batch(); + let count = 0; + for (const doc of [...rejections, ...challenges]) { + batch.delete(doc); + count++; + if (count % 400 === 0) { // stay within batch limits + batches.push(batch); + batch = db.batch(); + } + } + batches.push(batch); + for (const b of batches) { await b.commit(); } + + // Delete user root doc if present + await userRef.delete().catch(() => {}); + + // Delete auth user + try { await auth.deleteUser(uid); } catch (e) { console.error('Auth delete failed', e); } + + return { ok: true }; +}); + +// Callable: Report a community post; increments report count and hides if exceeds threshold +export const reportPost = functions.https.onCall(async (data, context) => { + const uid = ensureAuthed(context); + await rateLimitCheck(uid, 'report', 10, 60); // 10 reports per minute + + const postId = String(data?.postId || ''); + if (!postId) { + throw new functions.https.HttpsError('invalid-argument', 'Invalid post'); + } + + const postRef = db.collection('community').doc(postId); + await db.runTransaction(async (tx) => { + const snap = await tx.get(postRef); + if (!snap.exists) throw new functions.https.HttpsError('not-found', 'Post not found'); + const data = snap.data() as any; + const reports = (data.reports || 0) + 1; + const update: any = { reports }; + if (reports >= 3) { + update.status = 'hidden'; + } + tx.set(postRef, update, { merge: true }); + tx.set(db.collection('communityReports').doc(), { uid, postId, createdAt: admin.firestore.FieldValue.serverTimestamp() }); + }); + + return { ok: true }; +}); diff --git a/functions/tests/firestore.rules.test.ts b/functions/tests/firestore.rules.test.ts new file mode 100644 index 0000000..48b0828 --- /dev/null +++ b/functions/tests/firestore.rules.test.ts @@ -0,0 +1,51 @@ +import { initializeTestEnvironment, RulesTestEnvironment, assertFails, assertSucceeds } from '@firebase/rules-unit-testing'; +import { readFileSync } from 'fs'; + +let testEnv: RulesTestEnvironment; + +before(async () => { + testEnv = await initializeTestEnvironment({ + projectId: 'resilientme-test', + firestore: { + rules: readFileSync(require('path').resolve(__dirname, '../../firestore.rules'), 'utf8'), + }, + }); +}); + +after(async () => { + await testEnv.cleanup(); +}); + +describe('Firestore Security Rules', () => { + it('denies community writes from client', async () => { + const ctx = testEnv.authenticatedContext('alice'); + const db = ctx.firestore(); + const ref = db.collection('community').doc('post1'); + await assertFails(ref.set({ content: 'hello', type: 'dating' })); + }); + + it('allows user to write own user doc and denies others', async () => { + const aliceCtx = testEnv.authenticatedContext('alice'); + const bobCtx = testEnv.authenticatedContext('bob'); + const aliceDb = aliceCtx.firestore(); + const bobDb = bobCtx.firestore(); + + await assertSucceeds(aliceDb.collection('users').doc('alice').collection('rejections').doc('1').set({ timestamp: new Date(), type: 'dating' })); + await assertFails(bobDb.collection('users').doc('alice').collection('rejections').doc('2').set({ timestamp: new Date(), type: 'job' })); + }); + + it('denies writes to server-only collections', async () => { + const ctx = testEnv.authenticatedContext('alice'); + const db = ctx.firestore(); + await assertFails(db.collection('userReactions').doc('x').set({})); + await assertFails(db.collection('communityReports').doc('x').set({})); + await assertFails(db.collection('rateLimits').doc('x').set({})); + }); + + it('allows public read of community', async () => { + const anon = testEnv.unauthenticatedContext(); + const db = anon.firestore(); + const ref = db.collection('community'); + await assertSucceeds(ref.get()); + }); +}); \ No newline at end of file diff --git a/functions/tests/storage.rules.test.ts b/functions/tests/storage.rules.test.ts new file mode 100644 index 0000000..a315726 --- /dev/null +++ b/functions/tests/storage.rules.test.ts @@ -0,0 +1,29 @@ +import { initializeTestEnvironment, RulesTestEnvironment, assertFails, assertSucceeds } from '@firebase/rules-unit-testing'; +import { readFileSync } from 'fs'; + +let testEnv: RulesTestEnvironment; + +before(async () => { + testEnv = await initializeTestEnvironment({ + projectId: 'resilientme-test', + storage: { + rules: readFileSync(require('path').resolve(__dirname, '../../storage.rules'), 'utf8'), + }, + }); +}); + +after(async () => { + await testEnv.cleanup(); +}); + +describe('Storage Security Rules', () => { + it('allows user to write own image and denies others', async () => { + const alice = testEnv.authenticatedContext('alice'); + const bob = testEnv.authenticatedContext('bob'); + const aliceStorage = alice.storage(); + const bobStorage = bob.storage(); + + await assertSucceeds(aliceStorage.ref('rejection_images/alice/img.jpg').putString('hello')); + await assertFails(bobStorage.ref('rejection_images/alice/img.jpg').putString('nope')); + }); +}); \ No newline at end of file diff --git a/functions/tsconfig.json b/functions/tsconfig.json index a7a58da..320293f 100644 --- a/functions/tsconfig.json +++ b/functions/tsconfig.json @@ -8,5 +8,5 @@ "outDir": "lib", "target": "ES2020" }, - "include": ["src"] + "include": ["src", "tests"] } diff --git a/ios/AppStorePrivacy.json b/ios/AppStorePrivacy.json new file mode 100644 index 0000000..07be761 --- /dev/null +++ b/ios/AppStorePrivacy.json @@ -0,0 +1,37 @@ +{ + "data_collected": [ + { + "category": "Contact Info", + "items": [ + { "type": "Email Address", "linked_to_user": true, "used_for_tracking": false, "purposes": ["Account Management"] } + ] + }, + { + "category": "Identifiers", + "items": [ + { "type": "User ID", "linked_to_user": true, "used_for_tracking": false, "purposes": ["App Functionality", "Analytics"] }, + { "type": "Device ID", "linked_to_user": true, "used_for_tracking": false, "purposes": ["App Functionality"] } + ] + }, + { + "category": "Usage Data", + "items": [ + { "type": "Product Interaction", "linked_to_user": false, "used_for_tracking": false, "purposes": ["Analytics"] } + ] + } + ], + "data_not_collected": [ + "Financial Info", + "Health & Fitness", + "Location", + "Sensitive Info", + "Contacts", + "Search History", + "Browsing History", + "Purchases", + "Diagnostics", + "Other Data" + ], + "tracking": false, + "third_party_advertising": false +} \ No newline at end of file diff --git a/ios/ResilientMe/AppDelegate.swift b/ios/ResilientMe/AppDelegate.swift index 2f46069..caf3d4e 100644 --- a/ios/ResilientMe/AppDelegate.swift +++ b/ios/ResilientMe/AppDelegate.swift @@ -3,6 +3,9 @@ import UIKit #if canImport(FirebaseCore) import FirebaseCore #endif +#if canImport(FirebaseMessaging) +import FirebaseMessaging +#endif class AppDelegate: NSObject, UIApplicationDelegate { var window: UIWindow? @@ -21,9 +24,23 @@ class AppDelegate: NSObject, UIApplicationDelegate { print("[Firebase] GoogleService-Info.plist missing or invalid. Skipping FirebaseApp.configure().") } #endif +#if canImport(FirebaseMessaging) + Messaging.messaging().delegate = self +#endif + // Initialize notification handling early + _ = NotificationManager.shared PerformanceMetrics.end("App Launch Configure", id: id) return true } } +#if canImport(FirebaseMessaging) +extension AppDelegate: MessagingDelegate { + func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { + guard let token = fcmToken else { return } + FirebaseManager.shared.saveFCMToken(token) + } +} +#endif + diff --git a/ios/ResilientMe/Info.plist b/ios/ResilientMe/Info.plist index c34e744..870510b 100644 --- a/ios/ResilientMe/Info.plist +++ b/ios/ResilientMe/Info.plist @@ -40,5 +40,26 @@ UIUserInterfaceStyle Dark + CFBundleURLTypes + + + CFBundleURLName + com.resilientme.app + CFBundleURLSchemes + + resilientme + + + + UIBackgroundModes + + remote-notification + + NSPhotoLibraryUsageDescription + Allow access to choose a screenshot to attach to a log. + NSUserTrackingUsageDescription + We use analytics to improve your experience. You can opt out at any time. + FirebaseMessagingAutoInitEnabled + diff --git a/ios/ResilientMe/Managers/AnalyticsManager.swift b/ios/ResilientMe/Managers/AnalyticsManager.swift index 54e588f..011f58f 100644 --- a/ios/ResilientMe/Managers/AnalyticsManager.swift +++ b/ios/ResilientMe/Managers/AnalyticsManager.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FirebaseAnalytics) +import FirebaseAnalytics +#endif struct WeeklyStats { let totalLogs: Int @@ -12,13 +15,39 @@ final class AnalyticsManager: ObservableObject { @Published var timeframe: TimeFrame = .week @Published var recoveryTrend: [TrendPoint] = [] - func trackRejectionLogged(type: RejectionType) { - // TODO: hook to Firebase Analytics later - recalculate() + // MARK: - Tracking + static func logEvent(_ name: String, params: [String: Any]? = nil) { + #if canImport(FirebaseAnalytics) + Analytics.logEvent(name, parameters: params) + #endif } + static func trackRejectionLogged(type: RejectionType) { + logEvent("rejection_log", params: ["type": type.rawValue]) + } + + static func trackChallengeComplete() { + logEvent("challenge_complete") + } + + static func trackChallengeSkip() { + logEvent("challenge_skip") + } + + static func trackCommunityPost() { + logEvent("community_post") + } + + static func trackReactionAdd(_ reaction: Reaction) { + logEvent("reaction_add", params: ["reaction": reaction.rawValue]) + } + + static func trackScreenView(_ name: String) { + logEvent(AnalyticsEventScreenView, params: [AnalyticsParameterScreenName: name]) + } + + // MARK: - Derived metrics func recalculate() { - // compute off-main to keep UI smooth let items = RejectionManager.shared.recent(days: timeframe.days) let total = items.count let high = items.filter { $0.emotionalImpact >= 7 }.count diff --git a/ios/ResilientMe/Managers/CommunityManager.swift b/ios/ResilientMe/Managers/CommunityManager.swift index 0728817..f1e3441 100644 --- a/ios/ResilientMe/Managers/CommunityManager.swift +++ b/ios/ResilientMe/Managers/CommunityManager.swift @@ -3,6 +3,9 @@ import Foundation #if canImport(FirebaseFirestore) import FirebaseFirestore #endif +#if canImport(FirebaseFunctions) +import FirebaseFunctions +#endif struct CommunityStory: Identifiable, Codable { let id: String @@ -24,12 +27,14 @@ struct CommunityStory: Identifiable, Codable { final class CommunityManager: ObservableObject { @Published private(set) var stories: [CommunityStory] = [] + @Published var isLoading: Bool = false func loadStories() async { #if canImport(FirebaseFirestore) guard FirebaseManager.shared.isConfigured else { return } + await MainActor.run { self.isLoading = true } let db = Firestore.firestore() - let snapshot = try? await db.collection("community").order(by: "createdAt", descending: true).limit(to: 100).getDocuments() + let snapshot = try? await db.collection("community").whereField("status", isEqualTo: "visible").order(by: "createdAt", descending: true).limit(to: 100).getDocuments() let list: [CommunityStory] = snapshot?.documents.compactMap { doc in let data = doc.data() guard let typeRaw = data["type"] as? String, @@ -41,7 +46,10 @@ final class CommunityManager: ObservableObject { reactionsMap.forEach { key, value in if let r = Reaction(rawValue: key) { reactions[r] = value } } return CommunityStory(id: doc.documentID, type: type, content: content, createdAt: ts.dateValue(), reactions: reactions, userReaction: nil) } ?? [] - await MainActor.run { self.stories = list } + await MainActor.run { + self.stories = list + self.isLoading = false + } #endif } @@ -51,26 +59,45 @@ final class CommunityManager: ObservableObject { } func addReaction(to story: CommunityStory, reaction: Reaction) { - #if canImport(FirebaseFirestore) + // optimistic update + if let idx = stories.firstIndex(where: { $0.id == story.id }) { + stories[idx].reactions[reaction, default: 0] += 1 + stories[idx].userReaction = reaction + } + #if canImport(FirebaseFunctions) guard FirebaseManager.shared.isConfigured else { return } - let db = Firestore.firestore() - let ref = db.collection("community").document(story.id) - let key = reaction.rawValue - ref.setData(["reactions.\(key)": FieldValue.increment(Int64(1))], merge: true) + let functions = Functions.functions() + let payload: [String: Any] = [ + "postId": story.id, + "reaction": reaction.rawValue + ] + functions.httpsCallable("reactToPost").call(payload) { result, error in + if let error = error { print("[Functions] reactToPost error: \(error)") } + } #endif } func submitStory(type: RejectionType, content: String) async throws { - #if canImport(FirebaseFirestore) + #if canImport(FirebaseFunctions) guard FirebaseManager.shared.isConfigured else { return } - let db = Firestore.firestore() + let functions = Functions.functions() + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + let sanitized = trimmed.replacingOccurrences(of: "[<>]", with: "", options: .regularExpression) let payload: [String: Any] = [ "type": type.rawValue, - "content": content, - "createdAt": Timestamp(date: Date()), - "reactions": [:] + "content": sanitized ] - _ = try await db.collection("community").addDocument(data: payload) + _ = try await functions.httpsCallable("createCommunityPost").call(payload) + #endif + } + + func report(story: CommunityStory) { + #if canImport(FirebaseFunctions) + guard FirebaseManager.shared.isConfigured else { return } + let functions = Functions.functions() + functions.httpsCallable("reportPost").call(["postId": story.id]) { result, error in + if let error = error { print("[Functions] reportPost error: \(error)") } + } #endif } } diff --git a/ios/ResilientMe/Managers/FirebaseManager.swift b/ios/ResilientMe/Managers/FirebaseManager.swift index 28265b6..240a7d0 100644 --- a/ios/ResilientMe/Managers/FirebaseManager.swift +++ b/ios/ResilientMe/Managers/FirebaseManager.swift @@ -7,6 +7,12 @@ import FirebaseCore #if canImport(FirebaseAuth) import FirebaseAuth #endif +#if canImport(FirebaseFirestore) +import FirebaseFirestore +#endif +#if canImport(FirebaseMessaging) +import FirebaseMessaging +#endif struct AppUser { let uid: String @@ -48,6 +54,7 @@ final class FirebaseManager: ObservableObject { let user = result.user let appUser = AppUser(uid: user.uid, isAnonymous: user.isAnonymous, email: user.email) await MainActor.run { self.currentUser = appUser } + registerMessagingToken() return appUser #else throw NSError(domain: "Firebase", code: -2, userInfo: [NSLocalizedDescriptionKey: "FirebaseAuth not available"]) @@ -62,6 +69,7 @@ final class FirebaseManager: ObservableObject { let user = result.user let appUser = AppUser(uid: user.uid, isAnonymous: user.isAnonymous, email: user.email) await MainActor.run { self.currentUser = appUser } + registerMessagingToken() return appUser #else throw NSError(domain: "Firebase", code: -2, userInfo: [NSLocalizedDescriptionKey: "FirebaseAuth not available"]) @@ -74,6 +82,22 @@ final class FirebaseManager: ObservableObject { currentUser = nil #endif } + + func saveFCMToken(_ token: String) { + #if canImport(FirebaseFirestore) + guard let uid = currentUser?.uid else { return } + let db = Firestore.firestore() + db.collection("users").document(uid).setData(["fcmToken": token], merge: true) + #endif + } + + private func registerMessagingToken() { + #if canImport(FirebaseMessaging) + Messaging.messaging().token { token, error in + if let token = token { self.saveFCMToken(token) } + } + #endif + } } diff --git a/ios/ResilientMe/Managers/Haptics.swift b/ios/ResilientMe/Managers/Haptics.swift new file mode 100644 index 0000000..0db8ce1 --- /dev/null +++ b/ios/ResilientMe/Managers/Haptics.swift @@ -0,0 +1,17 @@ +import Foundation +import UIKit + +enum Haptics { + static func success() { + let gen = UINotificationFeedbackGenerator() + gen.notificationOccurred(.success) + } + static func light() { + let gen = UIImpactFeedbackGenerator(style: .light) + gen.impactOccurred() + } + static func medium() { + let gen = UIImpactFeedbackGenerator(style: .medium) + gen.impactOccurred() + } +} \ No newline at end of file diff --git a/ios/ResilientMe/Managers/NotificationManager.swift b/ios/ResilientMe/Managers/NotificationManager.swift index 7ad96e6..cd7169a 100644 --- a/ios/ResilientMe/Managers/NotificationManager.swift +++ b/ios/ResilientMe/Managers/NotificationManager.swift @@ -4,9 +4,19 @@ import UserNotifications final class NotificationManager: NSObject, ObservableObject, UNUserNotificationCenterDelegate { static let shared = NotificationManager() + var onDeepLink: ((String) -> Void)? + private override init() { super.init() UNUserNotificationCenter.current().delegate = self + registerCategories() + } + + private func registerCategories() { + let openRecovery = UNNotificationAction(identifier: "OPEN_RECOVERY", title: "Open Recovery", options: [.foreground]) + let dailyCategory = UNNotificationCategory(identifier: "DAILY_CHECKIN", actions: [openRecovery], intentIdentifiers: [], options: []) + let recoveryCategory = UNNotificationCategory(identifier: "RECOVERY_FOLLOWUP", actions: [openRecovery], intentIdentifiers: [], options: []) + UNUserNotificationCenter.current().setNotificationCategories([dailyCategory, recoveryCategory]) } func requestPermission() { @@ -14,17 +24,28 @@ final class NotificationManager: NSObject, ObservableObject, UNUserNotificationC } func scheduleDailyCheckIn(hour: Int = 20) { + // remove existing + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ["daily-checkin"]) var dateComponents = DateComponents() dateComponents.hour = hour let content = UNMutableNotificationContent() content.title = "How are you feeling today?" content.body = "Take 30 seconds to check in with yourself" content.sound = .default + content.categoryIdentifier = "DAILY_CHECKIN" let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true) let request = UNNotificationRequest(identifier: "daily-checkin", content: content, trigger: trigger) UNUserNotificationCenter.current().add(request) } + func setDailyCheckInEnabled(_ enabled: Bool) { + if enabled { + scheduleDailyCheckIn(hour: 20) + } else { + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ["daily-checkin"]) + } + } + func scheduleRecoveryFollowUps() { let recentHigh = RejectionManager.shared.recentHighImpact() for r in recentHigh { @@ -32,11 +53,38 @@ final class NotificationManager: NSObject, ObservableObject, UNUserNotificationC content.title = "How are you doing?" content.body = "Yesterday was tough. You're stronger than you know." content.sound = .default + content.categoryIdentifier = "RECOVERY_FOLLOWUP" let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 24 * 60 * 60, repeats: false) let request = UNNotificationRequest(identifier: "recovery-followup-\(r.id.uuidString)", content: content, trigger: trigger) UNUserNotificationCenter.current().add(request) } } + + func setRecoveryFollowUpsEnabled(_ enabled: Bool) { + if enabled { return } + UNUserNotificationCenter.current().getPendingNotificationRequests { requests in + let ids = requests.map { $0.identifier }.filter { $0.hasPrefix("recovery-followup-") } + if !ids.isEmpty { + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids) + } + } + } + + // Present notifications while app is in foreground + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.banner, .list, .sound]) + } + + // Handle deep-link from notification response + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + let userInfo = response.notification.request.content.userInfo + if let link = userInfo["deep_link"] as? String { + onDeepLink?(link) + } else if response.notification.request.content.categoryIdentifier == "DAILY_CHECKIN" || response.actionIdentifier == "OPEN_RECOVERY" { + onDeepLink?("resilientme://recovery") + } + completionHandler() + } } diff --git a/ios/ResilientMe/Managers/RejectionManager.swift b/ios/ResilientMe/Managers/RejectionManager.swift index 80b49dd..9691e4c 100644 --- a/ios/ResilientMe/Managers/RejectionManager.swift +++ b/ios/ResilientMe/Managers/RejectionManager.swift @@ -24,8 +24,34 @@ final class RejectionManager: ObservableObject { do { try bg.save() } catch { print("Core Data save error: \(error)") } } - // Firestore sync (non-blocking) - FirestoreSyncService.shared.sync(entry: entry) + // Firestore sync via repository (non-blocking) + FirestoreSyncService.shared.save(entry: entry) + } + + func update(id: UUID, type: RejectionType, emotionalImpact: Double, note: String?) { + let bg = CoreDataStack.shared.newBackgroundContext() + bg.perform { + let request = NSFetchRequest(entityName: "RejectionCD") + request.predicate = NSPredicate(format: "id == %@", id as CVarArg) + if let obj = try? bg.fetch(request).first { + obj.setValue(type.rawValue, forKey: "type") + obj.setValue(emotionalImpact, forKey: "emotionalImpact") + obj.setValue(note, forKey: "note") + do { try bg.save() } catch { print("Core Data update error: \(error)") } + } + } + } + + func delete(id: UUID) { + let bg = CoreDataStack.shared.newBackgroundContext() + bg.perform { + let request = NSFetchRequest(entityName: "RejectionCD") + request.predicate = NSPredicate(format: "id == %@", id as CVarArg) + if let results = try? bg.fetch(request) { + for obj in results { bg.delete(obj) } + do { try bg.save() } catch { print("Core Data delete error: \(error)") } + } + } } func recent(days: Int) -> [RejectionEntry] { @@ -43,7 +69,7 @@ final class RejectionManager: ObservableObject { else { return nil } let impact = obj.value(forKey: "emotionalImpact") as? Double ?? 0 let note = obj.value(forKey: "note") as? String - return RejectionEntry(id: id, type: type, emotionalImpact: impact, note: note, timestamp: ts) + return RejectionEntry(id: id, type: type, emotionalImpact: impact, note: note, timestamp: ts, imageUrl: nil) } } catch { print("Core Data fetch error: \(error)") diff --git a/ios/ResilientMe/Managers/RemoteConfigManager.swift b/ios/ResilientMe/Managers/RemoteConfigManager.swift new file mode 100644 index 0000000..ac8aa36 --- /dev/null +++ b/ios/ResilientMe/Managers/RemoteConfigManager.swift @@ -0,0 +1,35 @@ +import Foundation + +#if canImport(FirebaseRemoteConfig) +import FirebaseRemoteConfig +#endif + +final class RemoteConfigManager: ObservableObject { + @Published var communityEnabled: Bool = true + @Published var challengeDifficulty: String = "" + + #if canImport(FirebaseRemoteConfig) + private let rc = RemoteConfig.remoteConfig() + #endif + + init() { + #if canImport(FirebaseRemoteConfig) + let defaults: [String: NSObject] = [ + "communityEnabled": true as NSNumber, + "challengeDifficulty": "" as NSString + ] + rc.setDefaults(defaults) + #endif + } + + func fetchAndActivate() { + #if canImport(FirebaseRemoteConfig) + rc.fetchAndActivate { status, _ in + DispatchQueue.main.async { + self.communityEnabled = self.rc["communityEnabled"].boolValue + self.challengeDifficulty = self.rc["challengeDifficulty"].stringValue ?? "" + } + } + #endif + } +} \ No newline at end of file diff --git a/ios/ResilientMe/Models/Reaction.swift b/ios/ResilientMe/Models/Reaction.swift index d2e1662..a19b439 100644 --- a/ios/ResilientMe/Models/Reaction.swift +++ b/ios/ResilientMe/Models/Reaction.swift @@ -5,6 +5,15 @@ enum Reaction: String, CaseIterable, Codable, Hashable { case relate = "πŸ˜”" case celebrate = "πŸŽ‰" case hug = "πŸ«‚" + + var accessibilityLabel: String { + switch self { + case .support: return "Support" + case .relate: return "Relate" + case .celebrate: return "Celebrate" + case .hug: return "Hug" + } + } } diff --git a/ios/ResilientMe/Models/RejectionEntry.swift b/ios/ResilientMe/Models/RejectionEntry.swift index ea01425..ba67557 100644 --- a/ios/ResilientMe/Models/RejectionEntry.swift +++ b/ios/ResilientMe/Models/RejectionEntry.swift @@ -1,11 +1,12 @@ import Foundation -struct RejectionEntry: Identifiable { +struct RejectionEntry: Identifiable, Codable { let id: UUID let type: RejectionType let emotionalImpact: Double let note: String? let timestamp: Date + var imageUrl: String? } diff --git a/ios/ResilientMe/PrivacyInfo.xcprivacy b/ios/ResilientMe/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..1e9ec7a --- /dev/null +++ b/ios/ResilientMe/PrivacyInfo.xcprivacy @@ -0,0 +1,31 @@ +{ + "NSPrivacyTracking": false, + "NSPrivacyTrackingDomains": [], + "NSPrivacyCollectedDataTypes": [ + { + "NSPrivacyCollectedDataType": "User ID", + "NSPrivacyCollectedDataTypeLinked": true, + "NSPrivacyCollectedDataTypeTracking": false, + "NSPrivacyCollectedDataTypePurposes": ["App Functionality", "Analytics"] + }, + { + "NSPrivacyCollectedDataType": "Device ID", + "NSPrivacyCollectedDataTypeLinked": true, + "NSPrivacyCollectedDataTypeTracking": false, + "NSPrivacyCollectedDataTypePurposes": ["App Functionality"] + }, + { + "NSPrivacyCollectedDataType": "Product Interaction", + "NSPrivacyCollectedDataTypeLinked": false, + "NSPrivacyCollectedDataTypeTracking": false, + "NSPrivacyCollectedDataTypePurposes": ["Analytics"] + }, + { + "NSPrivacyCollectedDataType": "Email Address", + "NSPrivacyCollectedDataTypeLinked": true, + "NSPrivacyCollectedDataTypeTracking": false, + "NSPrivacyCollectedDataTypePurposes": ["Account Management"] + } + ], + "NSPrivacyAccessedAPITypes": [] +} \ No newline at end of file diff --git a/ios/ResilientMe/ResilientMeApp.swift b/ios/ResilientMe/ResilientMeApp.swift index e34d593..0945273 100644 --- a/ios/ResilientMe/ResilientMeApp.swift +++ b/ios/ResilientMe/ResilientMeApp.swift @@ -6,13 +6,24 @@ struct ResilientMeApp: App { init() {} @StateObject private var analyticsManager = AnalyticsManager() + @State private var selectedTab: Int = 0 + @AppStorage("onboarding_complete") private var onboardingComplete: Bool = false var body: some Scene { WindowGroup { - ContentView() - .environmentObject(analyticsManager) - .preferredColorScheme(.dark) - .background(Color.resilientBackground.ignoresSafeArea()) + ZStack { + ContentView(selectedTab: $selectedTab) + .environmentObject(analyticsManager) + .preferredColorScheme(.dark) + .background(Color.resilientBackground.ignoresSafeArea()) + .onAppear { + NotificationManager.shared.onDeepLink = { link in + if link.contains("recovery") { selectedTab = 2 } + else if link.contains("history") { selectedTab = 5 } + } + } + if !onboardingComplete { OnboardingView() } + } } } } diff --git a/ios/ResilientMe/Services/FirestoreSyncService.swift b/ios/ResilientMe/Services/FirestoreSyncService.swift index eefa8c0..251079e 100644 --- a/ios/ResilientMe/Services/FirestoreSyncService.swift +++ b/ios/ResilientMe/Services/FirestoreSyncService.swift @@ -5,29 +5,93 @@ import FirebaseFirestore import FirebaseFirestoreSwift #endif -final class FirestoreSyncService { +protocol RejectionRepository { + func save(entry: RejectionEntry) +} + +final class FirestoreSyncService: RejectionRepository { static let shared = FirestoreSyncService() - private init() {} + private init() { + loadPendingFromDisk() + } + + private let queue = DispatchQueue(label: "com.resilientme.sync", qos: .utility) + private var pending: [RejectionEntry] = [] { didSet { savePendingToDisk() } } + private var retryDelay: TimeInterval = 1 - func sync(entry: RejectionEntry) { + func save(entry: RejectionEntry) { + // Enqueue and process in background + queue.async { [weak self] in + self?.pending.append(entry) + self?.processQueue() + } + } + + private func processQueue() { #if canImport(FirebaseFirestore) guard FirebaseManager.shared.isConfigured else { return } guard let uid = FirebaseManager.shared.currentUser?.uid else { return } let db = Firestore.firestore() - let doc = db.collection("users").document(uid).collection("rejections").document(entry.id.uuidString) - - let payload: [String: Any] = [ - "id": entry.id.uuidString, - "type": entry.type.rawValue, - "emotionalImpact": entry.emotionalImpact, - "note": entry.note as Any, - "timestamp": Timestamp(date: entry.timestamp) - ] - doc.setData(payload, merge: true) { error in - if let error = error { print("[Firestore] Sync error: \(error)") } + + while !pending.isEmpty { + let entry = pending.removeFirst() + let doc = db.collection("users").document(uid).collection("rejections").document(entry.id.uuidString) + let payload: [String: Any] = [ + "id": entry.id.uuidString, + "type": entry.type.rawValue, + "emotionalImpact": entry.emotionalImpact, + "note": entry.note as Any, + "timestamp": Timestamp(date: entry.timestamp) + ] + let semaphore = DispatchSemaphore(value: 0) + var hadError = false + doc.setData(payload, merge: true) { error in + if let error = error { + print("[Firestore] Sync error: \(error)") + hadError = true + } + semaphore.signal() + } + semaphore.wait() + if hadError { + // backoff requeue and stop processing + pending.insert(entry, at: 0) + retryDelay = min(retryDelay * 2, 60) + queue.asyncAfter(deadline: .now() + retryDelay) { [weak self] in self?.processQueue() } + return + } else { + retryDelay = 1 + } } #endif } + + // MARK: - Persistence + private func savePendingToDisk() { + do { + let data = try JSONEncoder().encode(pending) + try data.write(to: pendingURL(), options: .atomic) + } catch { + print("[Sync] Failed to persist queue: \(error)") + } + } + + private func loadPendingFromDisk() { + do { + let url = pendingURL() + guard FileManager.default.fileExists(atPath: url.path) else { return } + let data = try Data(contentsOf: url) + let items = try JSONDecoder().decode([RejectionEntry].self, from: data) + pending = items + } catch { + print("[Sync] Failed to load queue: \(error)") + } + } + + private func pendingURL() -> URL { + let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + return dir.appendingPathComponent("sync_queue.json") + } } diff --git a/ios/ResilientMe/Views/Challenge/ChallengeView.swift b/ios/ResilientMe/Views/Challenge/ChallengeView.swift index 5a5e809..71d283f 100644 --- a/ios/ResilientMe/Views/Challenge/ChallengeView.swift +++ b/ios/ResilientMe/Views/Challenge/ChallengeView.swift @@ -1,8 +1,12 @@ import SwiftUI +#if canImport(FirebaseFirestore) +import FirebaseFirestore +#endif struct ChallengeView: View { @StateObject private var manager = ChallengeManager() @State private var challenge: Challenge? + @State private var showingShare = false var body: some View { NavigationView { @@ -25,10 +29,13 @@ struct ChallengeView: View { HStack { ResilientButton(title: "Complete", style: .primary) { manager.markCompleted(c) - challenge = manager.getTodaysChallenge() + AnalyticsManager.trackChallengeComplete() + showingShare = true + loadChallenge() } ResilientButton(title: "Skip", style: .secondary) { - challenge = manager.getTodaysChallenge() + AnalyticsManager.trackChallengeSkip() + loadChallenge() } } } else { @@ -44,8 +51,35 @@ struct ChallengeView: View { } .padding() .navigationTitle("Today's Challenge") - .onAppear { challenge = manager.getTodaysChallenge() } + .onAppear { loadChallenge() } + .sheet(isPresented: $showingShare) { + let text = "I just completed today’s resilience challenge on ResilientMe!" + ShareSheet(items: [text]) + } + } + } + + private func loadChallenge() { + #if canImport(FirebaseFirestore) + if FirebaseManager.shared.isConfigured, let uid = FirebaseManager.shared.currentUser?.uid { + let today = ISO8601DateFormatter().string(from: Calendar.current.startOfDay(for: Date())).prefix(10) + let doc = Firestore.firestore().collection("users").document(uid).collection("challenges").document(String(today)) + doc.getDocument { snap, _ in + if let data = snap?.data(), + let title = data["title"] as? String, + let description = data["description"] as? String, + let typeRaw = data["type"] as? String, + let type = RejectionType(rawValue: typeRaw), + let timeEstimate = data["timeEstimate"] as? String { + self.challenge = Challenge(title: title, description: description, type: type, difficulty: .beginner, points: data["points"] as? Int ?? 10, timeEstimate: timeEstimate) + return + } + self.challenge = manager.getTodaysChallenge() + } + return } + #endif + self.challenge = manager.getTodaysChallenge() } } diff --git a/ios/ResilientMe/Views/Community/CommunityView.swift b/ios/ResilientMe/Views/Community/CommunityView.swift index 8278f27..d69f8f0 100644 --- a/ios/ResilientMe/Views/Community/CommunityView.swift +++ b/ios/ResilientMe/Views/Community/CommunityView.swift @@ -12,29 +12,51 @@ struct CommunityView: View { ScrollView(.horizontal, showsIndicators: false) { HStack { FilterPill(title: "All", isSelected: selectedFilter == nil) { selectedFilter = nil } + .accessibilityLabel("Filter All") ForEach(RejectionType.allCases) { type in FilterPill(title: type.displayTitle, isSelected: selectedFilter == type) { selectedFilter = type } + .accessibilityLabel("Filter \(type.displayTitle)") } } .padding(.horizontal) } - List { - ForEach(manager.getStories(filter: selectedFilter)) { story in - CommunityStoryCard(story: story) { reaction in - manager.addReaction(to: story, reaction: reaction) + if manager.isLoading { + VStack(spacing: 12) { + ForEach(0..<6) { _ in SkeletonView(height: 72, cornerRadius: 12) } + }.padding() + } else if manager.getStories(filter: selectedFilter).isEmpty { + VStack(spacing: 12) { + Text("No stories yet.").font(.resilientHeadline) + Text("Share your first story. Someone will relate.").font(.resilientBody).foregroundColor(.secondary) + ResilientButton(title: "Share a story", style: .primary) { showingSubmission = true } + } + .resilientCard() + .padding() + } else { + List { + ForEach(manager.getStories(filter: selectedFilter)) { story in + CommunityStoryCard(story: story) { reaction in + // optimistic UI + Haptics.light() + manager.addReaction(to: story, reaction: reaction) + AnalyticsManager.trackReactionAdd(reaction) + } + .swipeActions { + Button(role: .destructive) { manager.report(story: story) } label: { Label("Report", systemImage: "exclamationmark.triangle") } + } } } + .listStyle(.plain) + .refreshable { await manager.loadStories() } } - .listStyle(.plain) - .refreshable { await manager.loadStories() } } .navigationTitle("Community") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Share") { showingSubmission = true } } } .sheet(isPresented: $showingSubmission) { StorySubmissionView(onSubmit: { type, text in - Task { try? await manager.submitStory(type: type, content: text); await manager.loadStories() } + Task { try? await manager.submitStory(type: type, content: text); AnalyticsManager.trackCommunityPost(); await manager.loadStories() } }) } - .onAppear { Task { await manager.loadStories() } } + .onAppear { AnalyticsManager.trackScreenView("Community"); Task { await manager.loadStories() } } } } .background(Color.resilientBackground.ignoresSafeArea()) @@ -77,6 +99,7 @@ struct CommunityStoryCard: View { Text("\(story.reactions[r] ?? 0)").font(.caption) } } + .accessibilityLabel(Text("Add reaction \(r.accessibilityLabel)")) } Spacer() } diff --git a/ios/ResilientMe/Views/Components/AgeGateOverlay.swift b/ios/ResilientMe/Views/Components/AgeGateOverlay.swift new file mode 100644 index 0000000..bde7224 --- /dev/null +++ b/ios/ResilientMe/Views/Components/AgeGateOverlay.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct AgeGateOverlay: View { + @AppStorage("age_gate_confirmed") private var confirmed: Bool = false + var onContinue: () -> Void + + var body: some View { + if !confirmed { + ZStack { + Color.black.opacity(0.75).ignoresSafeArea() + VStack(spacing: 16) { + Text("Age Confirmation").font(.title2).bold() + Text("You must be 18+ to use ResilientMe. By continuing, you confirm you are 18 or older.") + .multilineTextAlignment(.center) + .font(.body) + HStack(spacing: 12) { + Button("Exit") { exit(0) } + .foregroundColor(.red) + .padding(.horizontal, 16).padding(.vertical, 10) + .background(Color(.systemGray6)).cornerRadius(8) + Button("I am 18+") { + confirmed = true + onContinue() + } + .foregroundColor(.white) + .padding(.horizontal, 16).padding(.vertical, 10) + .background(Color.blue).cornerRadius(8) + } + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(12) + .padding(24) + } + } + } +} \ No newline at end of file diff --git a/ios/ResilientMe/Views/Components/CardStyle.swift b/ios/ResilientMe/Views/Components/CardStyle.swift new file mode 100644 index 0000000..eb97cb7 --- /dev/null +++ b/ios/ResilientMe/Views/Components/CardStyle.swift @@ -0,0 +1,14 @@ +import SwiftUI + +struct ResilientCard: ViewModifier { + func body(content: Content) -> some View { + content + .padding() + .background(Color(.systemGray6)) + .cornerRadius(16) + } +} + +extension View { + func resilientCard() -> some View { self.modifier(ResilientCard()) } +} \ No newline at end of file diff --git a/ios/ResilientMe/Views/Components/OnboardingView.swift b/ios/ResilientMe/Views/Components/OnboardingView.swift new file mode 100644 index 0000000..cf41229 --- /dev/null +++ b/ios/ResilientMe/Views/Components/OnboardingView.swift @@ -0,0 +1,52 @@ +import SwiftUI + +struct OnboardingView: View { + @AppStorage("onboarding_complete") private var complete: Bool = false + @State private var step: Int = 0 + + var body: some View { + VStack(spacing: 20) { + TabView(selection: $step) { + VStack(spacing: 12) { + Text("Welcome to ResilientMe").font(.title2).bold() + Text("Turn rejection into resilience. You log it, we help you bounce back.") + .multilineTextAlignment(.center) + .font(.body) + }.tag(0) + + VStack(spacing: 12) { + Text("Privacy First").font(.title2).bold() + Text("Anonymous by default. You control what you share. Biometric lock optional.") + .multilineTextAlignment(.center) + .font(.body) + }.tag(1) + + VStack(spacing: 12) { + Text("Stay on Track").font(.title2).bold() + Text("Enable daily check-ins to build your resilience streak.") + .multilineTextAlignment(.center) + .font(.body) + ResilientButton(title: "Enable Notifications", style: .primary) { + NotificationManager.shared.requestPermission() + } + }.tag(2) + } + .tabViewStyle(.page) + .indexViewStyle(.page(backgroundDisplayMode: .automatic)) + .frame(maxHeight: 280) + + HStack(spacing: 12) { + if step > 0 { Button("Back") { withAnimation { step -= 1 } } } + Spacer() + Button(step < 2 ? "Next" : "Get Started") { + if step < 2 { withAnimation { step += 1 } } + else { complete = true } + } + .buttonStyle(.borderedProminent) + } + .padding(.horizontal) + } + .padding() + .background(Color.resilientBackground.ignoresSafeArea()) + } +} \ No newline at end of file diff --git a/ios/ResilientMe/Views/Components/ShareSheet.swift b/ios/ResilientMe/Views/Components/ShareSheet.swift new file mode 100644 index 0000000..5b6d1ea --- /dev/null +++ b/ios/ResilientMe/Views/Components/ShareSheet.swift @@ -0,0 +1,12 @@ +import SwiftUI +import UIKit + +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} \ No newline at end of file diff --git a/ios/ResilientMe/Views/Components/SkeletonView.swift b/ios/ResilientMe/Views/Components/SkeletonView.swift new file mode 100644 index 0000000..6f278f1 --- /dev/null +++ b/ios/ResilientMe/Views/Components/SkeletonView.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct SkeletonView: View { + @State private var phase: CGFloat = 0 + var height: CGFloat = 14 + var cornerRadius: CGFloat = 8 + + var body: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(LinearGradient(gradient: Gradient(colors: [Color.gray.opacity(0.2), Color.gray.opacity(0.35), Color.gray.opacity(0.2)]), startPoint: .leading, endPoint: .trailing)) + .frame(height: height) + .mask( + Rectangle() + .fill( + LinearGradient(gradient: Gradient(colors: [Color.black.opacity(0.4), Color.black, Color.black.opacity(0.4)]), startPoint: .leading, endPoint: .trailing) + ) + .offset(x: phase) + ) + .onAppear { + withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) { + phase = 200 + } + } + } +} \ No newline at end of file diff --git a/ios/ResilientMe/Views/ContentView.swift b/ios/ResilientMe/Views/ContentView.swift index 4e972fc..2d11058 100644 --- a/ios/ResilientMe/Views/ContentView.swift +++ b/ios/ResilientMe/Views/ContentView.swift @@ -1,49 +1,65 @@ import SwiftUI struct ContentView: View { + @Binding var selectedTab: Int + @StateObject private var rc = RemoteConfigManager() + var body: some View { - TabView { - DashboardView() - .tabItem { - Label("Dashboard", systemImage: "chart.pie.fill") - } + ZStack { + TabView(selection: $selectedTab) { + DashboardView() + .tabItem { + Label("Dashboard", systemImage: "chart.pie.fill") + } + .tag(0) - RejectionLogView() - .tabItem { - Label("Quick Log", systemImage: "plus.circle.fill") - } + RejectionLogView() + .tabItem { + Label("Quick Log", systemImage: "plus.circle.fill") + } + .tag(1) - RecoveryHubView() - .tabItem { - Label("Recovery", systemImage: "heart.text.square.fill") - } + RecoveryHubView() + .tabItem { + Label("Recovery", systemImage: "heart.text.square.fill") + } + .tag(2) - ChallengeView() - .tabItem { - Label("Challenge", systemImage: "flag.checkered") - } + ChallengeView() + .tabItem { + Label("Challenge", systemImage: "flag.checkered") + } + .tag(3) - CommunityView() - .tabItem { - Label("Community", systemImage: "person.3.fill") + if rc.communityEnabled { + CommunityView() + .tabItem { + Label("Community", systemImage: "person.3.fill") + } + .tag(4) } - HistoryView() - .tabItem { - Label("History", systemImage: "clock.fill") - } + HistoryView() + .tabItem { + Label("History", systemImage: "clock.fill") + } + .tag(5) - SettingsView() - .tabItem { - Label("Settings", systemImage: "gearshape.fill") - } + SettingsView() + .tabItem { + Label("Settings", systemImage: "gearshape.fill") + } + .tag(6) + } + AgeGateOverlay { } } + .onAppear { rc.fetchAndActivate() } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { - ContentView() + ContentView(selectedTab: .constant(0)) } } diff --git a/ios/ResilientMe/Views/DashboardView.swift b/ios/ResilientMe/Views/DashboardView.swift index 1f6de78..eed3839 100644 --- a/ios/ResilientMe/Views/DashboardView.swift +++ b/ios/ResilientMe/Views/DashboardView.swift @@ -1,19 +1,33 @@ import SwiftUI +#if canImport(FirebaseFirestore) +import FirebaseFirestore +#endif struct DashboardView: View { @EnvironmentObject private var analyticsManager: AnalyticsManager @State private var patterns: [Pattern] = [] + @State private var isLoading: Bool = true var body: some View { NavigationView { ScrollView { VStack(spacing: 16) { - ResilienceRing(score: analyticsManager.currentResilienceScore) - WeeklyStatsCard(stats: analyticsManager.weeklyStats) - if !patterns.isEmpty { - PatternAlertsCard(patterns: patterns) + if isLoading { + SkeletonView(height: 180, cornerRadius: 16) + SkeletonView(height: 110, cornerRadius: 16) + SkeletonView(height: 140, cornerRadius: 16) + } else { + ResilienceRing(score: analyticsManager.currentResilienceScore) + .resilientCard() + WeeklyStatsCard(stats: analyticsManager.weeklyStats) + .resilientCard() + if !patterns.isEmpty { + PatternAlertsCard(patterns: patterns) + .resilientCard() + } + RecoveryTrendsChart(points: analyticsManager.recoveryTrend) + .resilientCard() } - RecoveryTrendsChart(points: analyticsManager.recoveryTrend) } .padding() } @@ -31,12 +45,41 @@ struct DashboardView: View { } } .onAppear { + isLoading = true analyticsManager.recalculate() - patterns = PatternAnalyzer.shared.analyzePatterns(for: RejectionManager.shared.recent(days: analyticsManager.timeframe.days)) + loadPatterns() + AnalyticsManager.trackScreenView("Dashboard") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { isLoading = false } } } .background(Color.resilientBackground.ignoresSafeArea()) } + + private func loadPatterns() { + #if canImport(FirebaseFirestore) + guard FirebaseManager.shared.isConfigured, let uid = FirebaseManager.shared.currentUser?.uid else { + patterns = PatternAnalyzer.shared.analyzePatterns(for: RejectionManager.shared.recent(days: analyticsManager.timeframe.days)) + return + } + let ref = Firestore.firestore().collection("users").document(uid).collection("aggregates").document("patterns") + ref.getDocument { snap, _ in + if let data = snap?.data(), let array = data["patterns"] as? [[String: Any]] { + let mapped = array.compactMap { dict -> Pattern? in + guard let title = dict["title"] as? String, + let description = dict["description"] as? String, + let insight = dict["insight"] as? String, + let actionable = dict["actionable"] as? String else { return nil } + return Pattern(title: title, description: description, insight: insight, actionable: actionable) + } + self.patterns = mapped + } else { + self.patterns = PatternAnalyzer.shared.analyzePatterns(for: RejectionManager.shared.recent(days: analyticsManager.timeframe.days)) + } + } + #else + patterns = PatternAnalyzer.shared.analyzePatterns(for: RejectionManager.shared.recent(days: analyticsManager.timeframe.days)) + #endif + } } struct PatternAlertsCard: View { let patterns: [Pattern] diff --git a/ios/ResilientMe/Views/HistoryView.swift b/ios/ResilientMe/Views/HistoryView.swift index 40b8c81..61f0215 100644 --- a/ios/ResilientMe/Views/HistoryView.swift +++ b/ios/ResilientMe/Views/HistoryView.swift @@ -1,37 +1,164 @@ import SwiftUI +#if canImport(FirebaseFirestore) +import FirebaseFirestore +#endif struct HistoryView: View { @State private var entries: [RejectionEntry] = [] + @State private var errorMessage: String? = nil + @State private var editing: RejectionEntry? = nil + @State private var isLoading: Bool = false var body: some View { NavigationView { AuthGate { - List(entries) { entry in - VStack(alignment: .leading, spacing: 4) { - Text(entry.type.rawValue) - .font(.headline) - HStack { - Text("Impact: \(Int(entry.emotionalImpact)) / 10") - Spacer() - Text(entry.timestamp, style: .date) - .foregroundColor(.secondary) - .font(.caption) + if let error = errorMessage { + Text(error).font(.footnote).foregroundColor(.white).padding(8).frame(maxWidth: .infinity).background(Color.red.opacity(0.8)).cornerRadius(8).padding([.horizontal, .top]) + } + if isLoading { + VStack(spacing: 12) { + ForEach(0..<6) { _ in SkeletonView(height: 82, cornerRadius: 12) } + }.padding() + } else if entries.isEmpty { + VStack(spacing: 12) { + Text("No logs yet.").font(.resilientHeadline) + Text("Your story starts when you log your first rejection.").font(.resilientBody).foregroundColor(.secondary) + ResilientButton(title: "Log one now", style: .primary) { } + } + .resilientCard() + .padding() + } else { + List(entries) { entry in + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top, spacing: 12) { + if let url = entry.imageUrl, let u = URL(string: url) { + AsyncImage(url: u) { phase in + switch phase { + case .empty: ProgressView().frame(width: 54, height: 54) + case .success(let img): img.resizable().scaledToFill().frame(width: 54, height: 54).clipped().cornerRadius(8) + case .failure: Image(systemName: "photo").frame(width: 54, height: 54) + @unknown default: EmptyView() + } + } + } + VStack(alignment: .leading, spacing: 4) { + Text(entry.type.rawValue) + .font(.headline) + HStack { + Text("Impact: \(Int(entry.emotionalImpact)) / 10") + Spacer() + Text(entry.timestamp, style: .date) + .foregroundColor(.secondary) + .font(.caption) + } + if let note = entry.note, !note.isEmpty { + Text(note).font(.subheadline) + } + } + } + HStack { + Button("Edit") { editing = entry } + Spacer() + } + } + .padding(.vertical, 6) + .accessibilityElement(children: .combine) + .accessibilityLabel(Text("\(entry.type.accessibilityTitle) rejection")) + .accessibilityValue(Text("Impact \(Int(entry.emotionalImpact)) out of 10 on \(entry.timestamp.formatted(date: .abbreviated, time: .omitted))")) + .swipeActions { + Button(role: .destructive) { delete(entry: entry) } label: { Label("Delete", systemImage: "trash") } } - if let note = entry.note, !note.isEmpty { - Text(note).font(.subheadline) } - } - .padding(.vertical, 6) - .accessibilityElement(children: .combine) - .accessibilityLabel(Text("\(entry.type.accessibilityTitle) rejection")) - .accessibilityValue(Text("Impact \(Int(entry.emotionalImpact)) out of 10 on \(entry.timestamp.formatted(date: .abbreviated, time: .omitted))")) } } .navigationTitle("History") - .onAppear { entries = RejectionManager.shared.recent(days: 30) } + .onAppear { AnalyticsManager.trackScreenView("History"); loadHistory() } + .sheet(item: $editing) { e in + EditEntryView(entry: e) { updated in + RejectionManager.shared.update(id: updated.id, type: updated.type, emotionalImpact: updated.emotionalImpact, note: updated.note) + #if canImport(FirebaseFirestore) + if FirebaseManager.shared.isConfigured, let uid = FirebaseManager.shared.currentUser?.uid { + let db = Firestore.firestore() + let doc = db.collection("users").document(uid).collection("rejections").document(updated.id.uuidString) + var payload: [String: Any] = [ + "type": updated.type.rawValue, + "emotionalImpact": updated.emotionalImpact, + "note": updated.note as Any + ] + doc.setData(payload, merge: true) + } + #endif + loadHistory() + } + } } .background(Color.resilientBackground.ignoresSafeArea()) } + + private func loadHistory() { + #if canImport(FirebaseFirestore) + if FirebaseManager.shared.isConfigured, let uid = FirebaseManager.shared.currentUser?.uid { + isLoading = true + let db = Firestore.firestore() + db.collection("users").document(uid).collection("rejections").order(by: "timestamp", descending: true).limit(to: 50).getDocuments { snap, err in + if let err = err { self.errorMessage = err.localizedDescription; self.isLoading = false; return } + if let docs = snap?.documents { + let mapped: [RejectionEntry] = docs.compactMap { d in + let data = d.data() + guard let typeRaw = data["type"] as? String, + let type = RejectionType(rawValue: typeRaw), + let impact = data["emotionalImpact"] as? Double, + let ts = data["timestamp"] as? Timestamp else { return nil } + let note = data["note"] as? String + return RejectionEntry(id: UUID(uuidString: d.documentID) ?? UUID(), type: type, emotionalImpact: impact, note: note, timestamp: ts.dateValue(), imageUrl: data["imageUrl"] as? String) + } + self.entries = mapped + } else { + self.entries = RejectionManager.shared.recent(days: 30) + } + self.isLoading = false + } + return + } + #endif + entries = RejectionManager.shared.recent(days: 30) + } + + private func delete(entry: RejectionEntry) { + #if canImport(FirebaseFirestore) + if FirebaseManager.shared.isConfigured, let uid = FirebaseManager.shared.currentUser?.uid { + let db = Firestore.firestore() + db.collection("users").document(uid).collection("rejections").document(entry.id.uuidString).delete { _ in + RejectionManager.shared.delete(id: entry.id) + loadHistory() + } + return + } + #endif + RejectionManager.shared.delete(id: entry.id) + loadHistory() + } +} + +struct EditEntryView: View { + @Environment(\.dismiss) private var dismiss + @State var entry: RejectionEntry + var onSave: (RejectionEntry) -> Void + + var body: some View { + NavigationView { + Form { + Picker("Type", selection: $entry.type) { ForEach(RejectionType.allCases) { Text($0.displayTitle).tag($0) } } + Slider(value: $entry.emotionalImpact, in: 1...10, step: 1) + TextField("Note", text: Binding(get: { entry.note ?? "" }, set: { entry.note = $0.isEmpty ? nil : $0 })) + } + .navigationTitle("Edit Entry") + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { Button("Save") { onSave(entry); dismiss() } } + } + } + } } struct HistoryView_Previews: PreviewProvider { diff --git a/ios/ResilientMe/Views/Recovery/RecoveryHubView.swift b/ios/ResilientMe/Views/Recovery/RecoveryHubView.swift index 38e3c47..05c6e0a 100644 --- a/ios/ResilientMe/Views/Recovery/RecoveryHubView.swift +++ b/ios/ResilientMe/Views/Recovery/RecoveryHubView.swift @@ -10,6 +10,14 @@ struct RecoveryHubView: View { if let rejection = last { RecoveryPlanCard(rejectionType: rejection.type) QuickRecoveryActions(rejectionType: rejection.type) + NavigationLink("Response Templates") { + ResponseTemplatesView(rejectionType: rejection.type) + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(12) } else { Text("No recent rejections. Log one to get a tailored recovery plan.") .font(.resilientBody) @@ -22,6 +30,8 @@ struct RecoveryHubView: View { .navigationTitle("Recovery Hub") .onAppear { last = RejectionManager.shared.recent(days: 30).first } } + .background(Color.resilientBackground.ignoresSafeArea()) + .onAppear { AnalyticsManager.trackScreenView("Recovery") } } } diff --git a/ios/ResilientMe/Views/Recovery/ResponseTemplatesView.swift b/ios/ResilientMe/Views/Recovery/ResponseTemplatesView.swift new file mode 100644 index 0000000..fd7ee12 --- /dev/null +++ b/ios/ResilientMe/Views/Recovery/ResponseTemplatesView.swift @@ -0,0 +1,56 @@ +import SwiftUI + +struct ResponseTemplate: Identifiable { + let id = UUID() + let title: String + let scenario: String + let template: String + let tone: String +} + +struct ResponseTemplatesView: View { + let rejectionType: RejectionType + @State private var selected: ResponseTemplate? + + var body: some View { + List(templates) { tpl in + VStack(alignment: .leading, spacing: 6) { + Text(tpl.title).font(.headline) + Text(tpl.scenario).font(.caption).foregroundColor(.secondary) + Text("Tone: \(tpl.tone)").font(.caption2).foregroundColor(.secondary) + } + .onTapGesture { selected = tpl } + } + .navigationTitle("Response Templates") + .sheet(item: $selected) { t in + VStack(alignment: .leading, spacing: 12) { + Text(t.title).font(.title3).bold() + Text(t.template).font(.body) + Spacer() + ResilientButton(title: "Copy", style: .primary) { + UIPasteboard.general.string = t.template + } + } + .padding() + } + } + + private var templates: [ResponseTemplate] { + switch rejectionType { + case .dating: + return [ + ResponseTemplate(title: "After Being Ghosted", scenario: "No reply after chats", template: "Hey, I know life gets busy. If you're not into this, no hard feelings. Wishing you well!", tone: "Graceful exit"), + ResponseTemplate(title: "Direct Rejection", scenario: "They said they're not interested", template: "Thanks for being honest. I appreciate it and wish you the best!", tone: "Mature response") + ] + case .job: + return [ + ResponseTemplate(title: "Following Up", scenario: "No response after applying", template: "Hi [Name], checking on my application for [Role]. I'm still very interested and would love to share more about my fit. Thanks for your time!", tone: "Professional persistence"), + ResponseTemplate(title: "Rejection Reply", scenario: "Got a rejection email", template: "Thank you for the update. I appreciate the opportunity. Please keep me in mind for future roles. Best to your team!", tone: "Professional grace") + ] + default: + return [ + ResponseTemplate(title: "Social Decline", scenario: "Plans fall through", template: "No worries at allβ€”maybe next time! Take care ✌️", tone: "Friendly") + ] + } + } +} \ No newline at end of file diff --git a/ios/ResilientMe/Views/RejectionLogView.swift b/ios/ResilientMe/Views/RejectionLogView.swift index ddf97bf..280c5ca 100644 --- a/ios/ResilientMe/Views/RejectionLogView.swift +++ b/ios/ResilientMe/Views/RejectionLogView.swift @@ -1,4 +1,7 @@ import SwiftUI +#if canImport(FirebaseFirestore) +import FirebaseFirestore +#endif struct RejectionLogView: View { @State private var rejectionType: RejectionType = .dating @@ -6,13 +9,14 @@ struct RejectionLogView: View { @State private var note: String = "" @State private var isSaving: Bool = false @State private var image: UIImage? = nil + @State private var showSuccess: Bool = false var body: some View { NavigationView { VStack(spacing: 16) { LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 2), spacing: 12) { ForEach(RejectionType.allCases) { type in - Button(action: { rejectionType = type }) { + Button(action: { rejectionType = type; Haptics.light() }) { Text(type.rawValue) .font(.headline) .frame(maxWidth: .infinity) @@ -48,12 +52,23 @@ struct RejectionLogView: View { .disabled(isSaving) .accessibilityLabel(isSaving ? "Logging" : "Log rejection") + if showSuccess { + Text("Logged. That’s strength.") + .font(.footnote) + .padding(8) + .frame(maxWidth: .infinity) + .background(Color.green.opacity(0.15)) + .cornerRadius(8) + .transition(.opacity) + } + Spacer() } .padding() .navigationTitle("Quick Log") } .background(Color.resilientBackground.ignoresSafeArea()) + .onAppear { AnalyticsManager.trackScreenView("QuickLog") } } private func logRejection() { @@ -63,15 +78,27 @@ struct RejectionLogView: View { type: rejectionType, emotionalImpact: emotionalImpact, note: note.isEmpty ? nil : note, - timestamp: Date() + timestamp: Date(), + imageUrl: nil ) RejectionManager.shared.save(entry: entry) - if let image = image { - Task { - let path = "rejection_images/\(FirebaseManager.shared.currentUser?.uid ?? "local")/\(entry.id).jpg" - _ = try? await ImageUploadService.shared.uploadImage(image, path: path) + #if canImport(FirebaseFirestore) + if FirebaseManager.shared.isConfigured, let uid = FirebaseManager.shared.currentUser?.uid { + let db = Firestore.firestore() + let doc = db.collection("users").document(uid).collection("rejections").document(entry.id.uuidString) + if let image = image { + Task { + if let url = try? await ImageUploadService.shared.uploadImage(image, path: "rejection_images/\(uid)/\(entry.id).jpg") { + doc.setData(["imageUrl": url], merge: true) + } + } } } + #endif + AnalyticsManager.trackRejectionLogged(type: rejectionType) + Haptics.success() + withAnimation { showSuccess = true } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) { withAnimation { showSuccess = false } } DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { isSaving = false note = "" diff --git a/ios/ResilientMe/Views/SettingsView.swift b/ios/ResilientMe/Views/SettingsView.swift index 7a00fbf..0fb1123 100644 --- a/ios/ResilientMe/Views/SettingsView.swift +++ b/ios/ResilientMe/Views/SettingsView.swift @@ -1,4 +1,7 @@ import SwiftUI +#if canImport(FirebaseFunctions) +import FirebaseFunctions +#endif struct SettingsView: View { @StateObject private var firebase = FirebaseManager.shared @@ -6,6 +9,8 @@ struct SettingsView: View { @State private var password: String = "" @State private var status: String = "" @State private var biometricEnabled: Bool = UserDefaults.standard.bool(forKey: "biometric_lock") + @AppStorage("notif_daily") private var notifDaily: Bool = true + @AppStorage("notif_followups") private var notifFollowups: Bool = true var body: some View { NavigationView { @@ -55,18 +60,27 @@ struct SettingsView: View { } Section(header: Text("Notifications")) { + Toggle("Daily check-in (8 PM)", isOn: Binding( + get: { notifDaily }, + set: { v in notifDaily = v; NotificationManager.shared.setDailyCheckInEnabled(v) } + )) + Toggle("Recovery follow-ups", isOn: Binding( + get: { notifFollowups }, + set: { v in notifFollowups = v; NotificationManager.shared.setRecoveryFollowUpsEnabled(v) } + )) Button("Request Permission") { NotificationManager.shared.requestPermission() status = "Notification permission requested" } - Button("Schedule Daily Check-in (8 PM)") { - NotificationManager.shared.scheduleDailyCheckIn(hour: 20) - status = "Daily check-in scheduled" - } - Button("Schedule Recovery Follow-ups (24h)") { - NotificationManager.shared.scheduleRecoveryFollowUps() - status = "Recovery follow-ups scheduled" + } + + Section(header: Text("Data")) { + Button("Request Data Export") { + Task { await requestExport() } } + .disabled(!firebase.isConfigured || firebase.currentUser == nil) + Button(role: .destructive) { Task { await requestDeletion() } } label: { Text("Request Account Deletion") } + .disabled(!firebase.isConfigured || firebase.currentUser == nil) } Section(header: Text("About")) { @@ -96,9 +110,34 @@ struct SettingsView: View { } } .navigationTitle("Settings") - .onAppear { firebase.refreshUser() } + .onAppear { firebase.refreshUser(); AnalyticsManager.trackScreenView("Settings") } } } + + private func requestExport() async { + #if canImport(FirebaseFunctions) + guard FirebaseManager.shared.isConfigured else { return } + let functions = Functions.functions() + do { + let result = try await functions.httpsCallable("requestDataExport").call([:]) + if let data = result.data as? [String: Any], let url = data["url"] as? String { + status = "Export ready: \(url)" + } else { + status = "Export requested" + } + } catch { status = "Export failed: \(error.localizedDescription)" } + #endif + } + + private func requestDeletion() async { + #if canImport(FirebaseFunctions) + let functions = Functions.functions() + do { + _ = try await functions.httpsCallable("requestAccountDeletion").call([:]) + status = "Deletion requested" + } catch { status = "Deletion failed: \(error.localizedDescription)" } + #endif + } } struct SettingsView_Previews: PreviewProvider { diff --git a/ios/ResilientMeUITests/ResilientMeUITests.swift b/ios/ResilientMeUITests/ResilientMeUITests.swift index 8342236..01bb164 100644 --- a/ios/ResilientMeUITests/ResilientMeUITests.swift +++ b/ios/ResilientMeUITests/ResilientMeUITests.swift @@ -5,15 +5,24 @@ final class ResilientMeUITests: XCTestCase { let app = XCUIApplication() app.launch() + // Dismiss age gate if present + if app.buttons["I am 18+"].exists { app.buttons["I am 18+"].tap() } + app.tabBars.buttons["Quick Log"].tap() app.sliders.element.adjust(toNormalizedSliderPosition: 0.6) let logButton = app.buttons["Log Rejection"] if logButton.exists { logButton.tap() } sleep(1) app.tabBars.buttons["History"].tap() - // No strict assertion due to varying state; presence of list indicates success XCTAssertTrue(app.navigationBars["History"].exists) } + + func testCommunityTabVisible() throws { + let app = XCUIApplication() + app.launch() + if app.buttons["I am 18+"].exists { app.buttons["I am 18+"].tap() } + XCTAssertTrue(app.tabBars.buttons["Community"].exists) + } } diff --git a/ios/fastlane/Appfile b/ios/fastlane/Appfile new file mode 100644 index 0000000..343020d --- /dev/null +++ b/ios/fastlane/Appfile @@ -0,0 +1 @@ +app_identifier("com.resilientme.app") \ No newline at end of file diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile new file mode 100644 index 0000000..beda041 --- /dev/null +++ b/ios/fastlane/Fastfile @@ -0,0 +1,37 @@ +default_platform(:ios) + +platform :ios do + desc 'Run unit and UI tests on iOS Simulator' + lane :test do + scan( + project: 'ResilientMe.xcodeproj', + scheme: 'ResilientMe', + devices: ['iPhone 14'], + clean: true + ) + end + + desc 'Build and upload to TestFlight (requires API key env vars)' + lane :beta do + api_key = app_store_connect_api_key( + key_id: ENV['APP_STORE_CONNECT_API_KEY_ID'], + issuer_id: ENV['APP_STORE_CONNECT_ISSUER_ID'], + key_content: ENV['APP_STORE_CONNECT_API_KEY'], + is_key_content_base64: true + ) + + build_app( + project: 'ResilientMe.xcodeproj', + scheme: 'ResilientMe', + export_method: 'app-store', + include_bitcode: false, + include_symbols: true + ) + + upload_to_testflight( + api_key: api_key, + skip_waiting_for_build_processing: true, + distribute_external: false + ) + end +end \ No newline at end of file